diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e574c..0618d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Global live update pause: disable automatic query refreshes on browser focus and reconnect, preventing paused workflow detail pages from re-fetching wait data outside the configured refresh interval. [PR #584](https://github.com/riverqueue/riverui/pull/584). +- Job detail: preserve line breaks in attempt logs while keeping structured JSON viewer content outside preformatted code markup. [PR #592](https://github.com/riverqueue/riverui/pull/592). ## [v0.16.0] - 2026-05-19 diff --git a/src/components/CopyablePanel.tsx b/src/components/CopyablePanel.tsx new file mode 100644 index 0000000..c659d30 --- /dev/null +++ b/src/components/CopyablePanel.tsx @@ -0,0 +1,111 @@ +import type { ReactNode } from "react"; + +import { CheckIcon } from "@heroicons/react/16/solid"; +import { ClipboardIcon } from "@heroicons/react/24/outline"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +import { ToastContentSuccess } from "@/components/Toast"; + +type CopyablePanelProps = { + children: ReactNode; + /** + * Additional class names to apply to the component. + */ + className?: string; + /** + * Raw text to be copied to clipboard. + */ + copyText: string; + /** + * The title to show in the copy confirmation toast. + * @default "Text" + */ + copyTitle?: string; +}; + +const styleConfig = { + container: { + base: "relative overflow-auto rounded-md bg-slate-50 dark:bg-slate-800", + font: { fontFamily: "var(--font-family-monospace, monospace)" }, + }, + content: { + base: "relative overflow-x-auto overscroll-y-auto p-1 pl-2 text-xs", + }, + header: { + base: "flex items-center justify-end bg-slate-100 px-2 py-1 text-xs dark:bg-slate-700", + textAlign: { textAlign: "right" as const }, + }, + icon: { + base: "h-3 w-3", + check: "text-green-500", + clipboard: + "text-slate-500 dark:text-slate-400 hover:text-brand-primary dark:hover:text-brand-primary", + }, +}; + +/** + * A panel that wraps copyable content with a copy button. + */ +export default function CopyablePanel({ + children, + className, + copyText, + copyTitle = "Text", +}: CopyablePanelProps) { + const [isCopied, setIsCopied] = useState(false); + + const copyToClipboard = () => { + navigator.clipboard.writeText(copyText).then( + () => { + setIsCopied(true); + toast.custom((t) => ( + + )); + setTimeout(() => { + setIsCopied(false); + }, 2000); + }, + (err) => { + console.error("Failed to copy text: ", err); + }, + ); + }; + + return ( +
+
+ +
+
{children}
+
+ ); +} diff --git a/src/components/JSONView.test.tsx b/src/components/JSONView.test.tsx index 7b6e242..eedf24d 100644 --- a/src/components/JSONView.test.tsx +++ b/src/components/JSONView.test.tsx @@ -47,7 +47,7 @@ describe("JSONView Component", () => { }; it("renders simple JSON data", () => { - render(); + const { container } = render(); // Check that key elements are in the document expect(screen.getByText(/"name"/)).toBeInTheDocument(); @@ -56,6 +56,7 @@ describe("JSONView Component", () => { expect(screen.getByText(/30/)).toBeInTheDocument(); expect(screen.getByText(/"isActive"/)).toBeInTheDocument(); expect(screen.getByText(/true/)).toBeInTheDocument(); + expect(container.querySelector("pre")).toBeNull(); }); it("renders object keys alphabetically at root and nested levels", () => { diff --git a/src/components/JSONView.tsx b/src/components/JSONView.tsx index c5400dc..e66ebfc 100644 --- a/src/components/JSONView.tsx +++ b/src/components/JSONView.tsx @@ -6,7 +6,7 @@ import { import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; import React from "react"; -import PlaintextPanel from "@/components/PlaintextPanel"; +import CopyablePanel from "@/components/CopyablePanel"; interface JSONNodeRendererProps { data: unknown; @@ -60,13 +60,15 @@ export default function JSONView({ ); return ( - + > +
+ {jsonContent} +
+ ); } diff --git a/src/components/JobAttempts.tsx b/src/components/JobAttempts.tsx index af5b503..753549c 100644 --- a/src/components/JobAttempts.tsx +++ b/src/components/JobAttempts.tsx @@ -365,9 +365,9 @@ function AttemptRow({ attemptInfo }: { attemptInfo: AttemptInfo }) {
{attemptInfo.logs.map((log, idx) => ( ))}
diff --git a/src/components/PlaintextPanel.stories.tsx b/src/components/PlaintextPanel.stories.tsx new file mode 100644 index 0000000..2154277 --- /dev/null +++ b/src/components/PlaintextPanel.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import PlaintextPanel from "./PlaintextPanel"; + +const meta: Meta = { + component: PlaintextPanel, + parameters: { + layout: "centered", + }, + title: "Components/PlaintextPanel", +}; + +export default meta; +type Story = StoryObj; + +const multilineLog = [ + 'time=2026-06-11T19:03:14Z level=info msg="starting job" job_id=123', + 'time=2026-06-11T19:03:15Z level=info msg="processing batch" batch=1 records=100', + '{"attempt":1,"event":"finished","metadata":{"customerID":"cus_123","queue":"default"}}', + " indented continuation line", + 'time=2026-06-11T19:03:16Z level=error msg="this is a very long log line that should demonstrate horizontal scrolling on narrow screens" details=abcdefghijklmnopqrstuvwxyz0123456789', +].join("\n"); + +export const MultilineLog: Story = { + args: { + copyTitle: "Log Entry", + text: multilineLog, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WrappedExpression: Story = { + args: { + codeClassName: "whitespace-pre-wrap break-all", + copyTitle: "Wait expression", + text: [ + "all([", + ' task.completed("prepare-customer-state"),', + ' signal.received("manual-approval")', + "])", + ].join("\n"), + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/src/components/PlaintextPanel.test.tsx b/src/components/PlaintextPanel.test.tsx new file mode 100644 index 0000000..d2f6f96 --- /dev/null +++ b/src/components/PlaintextPanel.test.tsx @@ -0,0 +1,53 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import toast from "react-hot-toast"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import PlaintextPanel from "./PlaintextPanel"; + +Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockImplementation(() => Promise.resolve()), + }, +}); + +vi.mock("react-hot-toast", () => ({ + default: { + custom: vi.fn(), + }, +})); + +describe("PlaintextPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders text in preformatted code markup", () => { + const text = [ + "time=2026-06-11T19:03:14Z level=info msg=starting", + " indented continuation", + "time=2026-06-11T19:03:15Z level=info msg=finished", + ].join("\n"); + + const { container } = render(); + + const pre = container.querySelector("pre"); + const code = pre?.querySelector("code"); + + expect(pre).toBeInTheDocument(); + expect(pre).toHaveClass("whitespace-pre"); + expect(code?.textContent).toBe(text); + }); + + it("copies the original text", async () => { + const text = "first line\nsecond line"; + + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId("text-copy-button")); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(text); + expect(toast.custom).toHaveBeenCalled(); + }); +}); diff --git a/src/components/PlaintextPanel.tsx b/src/components/PlaintextPanel.tsx index 90815d4..dab51cb 100644 --- a/src/components/PlaintextPanel.tsx +++ b/src/components/PlaintextPanel.tsx @@ -1,10 +1,4 @@ -import { CheckIcon } from "@heroicons/react/16/solid"; -import { ClipboardIcon } from "@heroicons/react/24/outline"; -import { useState } from "react"; -import React from "react"; -import toast from "react-hot-toast"; - -import { ToastContentSuccess } from "@/components/Toast"; +import CopyablePanel from "@/components/CopyablePanel"; type PlaintextPanelProps = { /** @@ -15,44 +9,20 @@ type PlaintextPanelProps = { * Additional class names to apply to the code element. */ codeClassName?: string; - /** - * The content to be displayed in the panel. - */ - content: React.ReactNode; /** * The title to show in the copy confirmation toast. * @default "Text" */ copyTitle?: string; /** - * Raw text to be copied to clipboard instead of extracting from content. + * The text to be displayed and copied. */ - rawText?: string; + text: string; }; const styleConfig = { - container: { - base: "relative overflow-auto rounded-md bg-slate-50 dark:bg-slate-800", - font: { fontFamily: "var(--font-family-monospace, monospace)" }, - }, - content: { - base: "relative text-xs p-1 pl-2", - code: "block text-slate-800 dark:text-slate-200", - layout: { - overflowX: "auto" as const, - overscrollBehaviorY: "auto" as const, - }, - }, - header: { - base: "flex items-center justify-end bg-slate-100 px-2 py-1 text-xs dark:bg-slate-700", - textAlign: { textAlign: "right" as const }, - }, - icon: { - base: "h-3 w-3", - check: "text-green-500", - clipboard: - "text-slate-500 dark:text-slate-400 hover:text-brand-primary dark:hover:text-brand-primary", - }, + code: "block text-slate-800 dark:text-slate-200", + pre: "m-0 whitespace-pre", }; /** @@ -61,85 +31,16 @@ const styleConfig = { export default function PlaintextPanel({ className, codeClassName, - content, copyTitle = "Text", - rawText, + text, }: PlaintextPanelProps) { - const [isCopied, setIsCopied] = useState(false); - - const copyToClipboard = () => { - const textContent = rawText || extractTextFromNode(content); - navigator.clipboard.writeText(textContent).then( - () => { - setIsCopied(true); - toast.custom((t) => ( - - )); - setTimeout(() => { - setIsCopied(false); - }, 2000); - }, - (err) => { - console.error("Failed to copy text: ", err); - }, - ); - }; - return ( -
- {/* Header with copy button */} -
- -
- {/* Content block */} -
- - {content} + +
+        
+          {text}
         
-      
-
+ + ); } - -// Helper function to extract text from React nodes -function extractTextFromNode(node: React.ReactNode): string { - if (typeof node === "string") return node; - if (typeof node === "number") return node.toString(); - if (Array.isArray(node)) return node.map(extractTextFromNode).join(" "); - if (React.isValidElement<{ children?: React.ReactNode }>(node)) { - const children = node.props.children; - if (children) return extractTextFromNode(children); - } - return ""; -} diff --git a/src/components/WorkflowGateInspector.tsx b/src/components/WorkflowGateInspector.tsx index e09ad20..97f672c 100644 --- a/src/components/WorkflowGateInspector.tsx +++ b/src/components/WorkflowGateInspector.tsx @@ -435,9 +435,8 @@ export default function WorkflowWaitInspector({