diff --git a/frontend/__tests__/components/buttons/copyable-content-wrapper.test.tsx b/frontend/__tests__/components/buttons/copyable-content-wrapper.test.tsx new file mode 100644 index 0000000000..7c7aaee48b --- /dev/null +++ b/frontend/__tests__/components/buttons/copyable-content-wrapper.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper"; + +describe("CopyableContentWrapper", () => { + it("should hide the copy button by default", () => { + render( + +

content

+
, + ); + + expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible(); + }); + + it("should show the copy button on hover", async () => { + const user = userEvent.setup(); + render( + +

content

+
, + ); + + await user.hover(screen.getByText("content")); + + expect(screen.getByTestId("copy-to-clipboard")).toBeVisible(); + }); + + it("should copy text to clipboard on click", async () => { + const user = userEvent.setup(); + render( + +

content

+
, + ); + + await user.click(screen.getByTestId("copy-to-clipboard")); + + await waitFor(() => + expect(navigator.clipboard.readText()).resolves.toBe("copy me"), + ); + }); + + it("should show copied state after clicking", async () => { + const user = userEvent.setup(); + render( + +

content

+
, + ); + + await user.click(screen.getByTestId("copy-to-clipboard")); + + expect(screen.getByTestId("copy-to-clipboard")).toHaveAttribute( + "aria-label", + "BUTTON$COPIED", + ); + }); +}); diff --git a/frontend/__tests__/components/features/markdown/code.test.tsx b/frontend/__tests__/components/features/markdown/code.test.tsx new file mode 100644 index 0000000000..c5ba1562af --- /dev/null +++ b/frontend/__tests__/components/features/markdown/code.test.tsx @@ -0,0 +1,37 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { code as Code } from "#/components/features/markdown/code"; + +describe("code (markdown)", () => { + it("should render inline code without a copy button", () => { + render(inline snippet); + + expect(screen.getByText("inline snippet")).toBeInTheDocument(); + expect(screen.queryByTestId("copy-to-clipboard")).not.toBeInTheDocument(); + }); + + it("should render a multiline code block with a copy button", () => { + render({"line1\nline2"}); + + expect(screen.getByText("line1 line2")).toBeInTheDocument(); + expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument(); + }); + + it("should render a syntax-highlighted block with a copy button", () => { + render({"console.log('hi')"}); + + expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument(); + }); + + it("should copy code block content to clipboard", async () => { + const user = userEvent.setup(); + render({"line1\nline2"}); + + await user.click(screen.getByTestId("copy-to-clipboard")); + + await waitFor(() => + expect(navigator.clipboard.readText()).resolves.toBe("line1\nline2"), + ); + }); +}); diff --git a/frontend/src/components/features/markdown/code.tsx b/frontend/src/components/features/markdown/code.tsx index 2a801f6848..ee04ce53b5 100644 --- a/frontend/src/components/features/markdown/code.tsx +++ b/frontend/src/components/features/markdown/code.tsx @@ -2,6 +2,7 @@ import React from "react"; import { ExtraProps } from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper"; // See https://github.com/remarkjs/react-markdown?tab=readme-ov-file#use-custom-components-syntax-highlight @@ -15,6 +16,7 @@ export function code({ React.HTMLAttributes & ExtraProps) { const match = /language-(\w+)/.exec(className || ""); // get the language + const codeString = String(children).replace(/\n$/, ""); if (!match) { const isMultiline = String(children).includes("\n"); @@ -37,29 +39,33 @@ export function code({ } return ( -
-        {String(children).replace(/\n$/, "")}
-      
+ +
+          {codeString}
+        
+
); } return ( - - {String(children).replace(/\n$/, "")} - + + + {codeString} + + ); } diff --git a/frontend/src/components/shared/buttons/copyable-content-wrapper.tsx b/frontend/src/components/shared/buttons/copyable-content-wrapper.tsx new file mode 100644 index 0000000000..fe9a4d837a --- /dev/null +++ b/frontend/src/components/shared/buttons/copyable-content-wrapper.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { CopyToClipboardButton } from "./copy-to-clipboard-button"; + +export function CopyableContentWrapper({ + text, + children, +}: { + text: string; + children: React.ReactNode; +}) { + const [isHovering, setIsHovering] = React.useState(false); + const [isCopied, setIsCopied] = React.useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setIsCopied(true); + }; + + React.useEffect(() => { + let timeout: NodeJS.Timeout; + if (isCopied) { + timeout = setTimeout(() => setIsCopied(false), 2000); + } + return () => clearTimeout(timeout); + }, [isCopied]); + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > +
+ +
+ {children} +
+ ); +}