From 4587c1c3593e201fb75dc59955ed66cca20c08ae Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Thu, 11 Jun 2026 20:10:57 -0500 Subject: [PATCH] preserve multiline job logs Job attempt logs can contain newline-delimited records, but the UI rendered them through a shared panel that treated all content as ordinary inline code. That collapsed multiline log output into a single visual paragraph and made the same component responsible for both copyable panel chrome and plaintext semantics. Split the reusable copy button shell into a copyable panel and make the plaintext panel string-only. Job logs and wait expressions now render through `pre`/`code` markup with explicit whitespace behavior, while the JSON viewer keeps its structured interactive React tree outside preformatted code markup. Focused component coverage now asserts multiline plaintext markup, exact clipboard text, and the JSON viewer's non-preformatted structure. Storybook also covers multiline logs and wrapped expressions so the two plaintext layouts are visible. --- CHANGELOG.md | 1 + src/components/CopyablePanel.tsx | 111 +++++++++++++++++++ src/components/JSONView.test.tsx | 3 +- src/components/JSONView.tsx | 14 +-- src/components/JobAttempts.tsx | 2 +- src/components/PlaintextPanel.stories.tsx | 52 +++++++++ src/components/PlaintextPanel.test.tsx | 53 ++++++++++ src/components/PlaintextPanel.tsx | 123 +++------------------- src/components/WorkflowGateInspector.tsx | 3 +- 9 files changed, 241 insertions(+), 121 deletions(-) create mode 100644 src/components/CopyablePanel.tsx create mode 100644 src/components/PlaintextPanel.stories.tsx create mode 100644 src/components/PlaintextPanel.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e574c8..0618d8e2 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 00000000..c659d30d --- /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 7b6e242d..eedf24de 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 c5400dc7..e66ebfcd 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 af5b5033..753549c8 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 00000000..21542771 --- /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 00000000..d2f6f961 --- /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 90815d41..dab51cb6 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 e09ad20a..97f672c1 100644 --- a/src/components/WorkflowGateInspector.tsx +++ b/src/components/WorkflowGateInspector.tsx @@ -435,9 +435,8 @@ export default function WorkflowWaitInspector({