Skip to content

Commit b596e58

Browse files
authored
preserve multiline job logs (#592)
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.
1 parent 2e4e2b3 commit b596e58

9 files changed

Lines changed: 241 additions & 121 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- 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).
13+
- 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).
1314

1415
## [v0.16.0] - 2026-05-19
1516

src/components/CopyablePanel.tsx

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { ReactNode } from "react";
2+
3+
import { CheckIcon } from "@heroicons/react/16/solid";
4+
import { ClipboardIcon } from "@heroicons/react/24/outline";
5+
import { useState } from "react";
6+
import toast from "react-hot-toast";
7+
8+
import { ToastContentSuccess } from "@/components/Toast";
9+
10+
type CopyablePanelProps = {
11+
children: ReactNode;
12+
/**
13+
* Additional class names to apply to the component.
14+
*/
15+
className?: string;
16+
/**
17+
* Raw text to be copied to clipboard.
18+
*/
19+
copyText: string;
20+
/**
21+
* The title to show in the copy confirmation toast.
22+
* @default "Text"
23+
*/
24+
copyTitle?: string;
25+
};
26+
27+
const styleConfig = {
28+
container: {
29+
base: "relative overflow-auto rounded-md bg-slate-50 dark:bg-slate-800",
30+
font: { fontFamily: "var(--font-family-monospace, monospace)" },
31+
},
32+
content: {
33+
base: "relative overflow-x-auto overscroll-y-auto p-1 pl-2 text-xs",
34+
},
35+
header: {
36+
base: "flex items-center justify-end bg-slate-100 px-2 py-1 text-xs dark:bg-slate-700",
37+
textAlign: { textAlign: "right" as const },
38+
},
39+
icon: {
40+
base: "h-3 w-3",
41+
check: "text-green-500",
42+
clipboard:
43+
"text-slate-500 dark:text-slate-400 hover:text-brand-primary dark:hover:text-brand-primary",
44+
},
45+
};
46+
47+
/**
48+
* A panel that wraps copyable content with a copy button.
49+
*/
50+
export default function CopyablePanel({
51+
children,
52+
className,
53+
copyText,
54+
copyTitle = "Text",
55+
}: CopyablePanelProps) {
56+
const [isCopied, setIsCopied] = useState(false);
57+
58+
const copyToClipboard = () => {
59+
navigator.clipboard.writeText(copyText).then(
60+
() => {
61+
setIsCopied(true);
62+
toast.custom((t) => (
63+
<ToastContentSuccess
64+
message={`${copyTitle} copied to clipboard`}
65+
t={t}
66+
/>
67+
));
68+
setTimeout(() => {
69+
setIsCopied(false);
70+
}, 2000);
71+
},
72+
(err) => {
73+
console.error("Failed to copy text: ", err);
74+
},
75+
);
76+
};
77+
78+
return (
79+
<div
80+
className={`${styleConfig.container.base} ${className || ""}`}
81+
style={styleConfig.container.font}
82+
>
83+
<div
84+
className={styleConfig.header.base}
85+
style={styleConfig.header.textAlign}
86+
>
87+
<button
88+
className="inline-flex cursor-pointer items-center rounded p-1"
89+
data-testid="text-copy-button"
90+
onClick={copyToClipboard}
91+
tabIndex={0}
92+
title="Copy to clipboard"
93+
type="button"
94+
>
95+
{isCopied ? (
96+
<CheckIcon
97+
aria-hidden="true"
98+
className={`${styleConfig.icon.base} ${styleConfig.icon.check}`}
99+
/>
100+
) : (
101+
<ClipboardIcon
102+
aria-hidden="true"
103+
className={`${styleConfig.icon.base} ${styleConfig.icon.clipboard}`}
104+
/>
105+
)}
106+
</button>
107+
</div>
108+
<div className={styleConfig.content.base}>{children}</div>
109+
</div>
110+
);
111+
}

src/components/JSONView.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe("JSONView Component", () => {
4747
};
4848

4949
it("renders simple JSON data", () => {
50-
render(<JSONView data={simpleData} />);
50+
const { container } = render(<JSONView data={simpleData} />);
5151

5252
// Check that key elements are in the document
5353
expect(screen.getByText(/"name"/)).toBeInTheDocument();
@@ -56,6 +56,7 @@ describe("JSONView Component", () => {
5656
expect(screen.getByText(/30/)).toBeInTheDocument();
5757
expect(screen.getByText(/"isActive"/)).toBeInTheDocument();
5858
expect(screen.getByText(/true/)).toBeInTheDocument();
59+
expect(container.querySelector("pre")).toBeNull();
5960
});
6061

6162
it("renders object keys alphabetically at root and nested levels", () => {

src/components/JSONView.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
77
import React from "react";
88

9-
import PlaintextPanel from "@/components/PlaintextPanel";
9+
import CopyablePanel from "@/components/CopyablePanel";
1010

1111
interface JSONNodeRendererProps {
1212
data: unknown;
@@ -60,13 +60,15 @@ export default function JSONView({
6060
);
6161

6262
return (
63-
<PlaintextPanel
63+
<CopyablePanel
6464
className={className}
65-
codeClassName="pl-6"
66-
content={jsonContent}
65+
copyText={JSON.stringify(sortedData, null, 2)}
6766
copyTitle={copyTitle}
68-
rawText={JSON.stringify(sortedData, null, 2)}
69-
/>
67+
>
68+
<div className="block pl-6 text-slate-800 dark:text-slate-200">
69+
{jsonContent}
70+
</div>
71+
</CopyablePanel>
7072
);
7173
}
7274

src/components/JobAttempts.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,9 +365,9 @@ function AttemptRow({ attemptInfo }: { attemptInfo: AttemptInfo }) {
365365
<div className="mt-2 space-y-2">
366366
{attemptInfo.logs.map((log, idx) => (
367367
<PlaintextPanel
368-
content={log.log}
369368
copyTitle="Log Entry"
370369
key={idx}
370+
text={log.log}
371371
/>
372372
))}
373373
</div>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
3+
import PlaintextPanel from "./PlaintextPanel";
4+
5+
const meta: Meta<typeof PlaintextPanel> = {
6+
component: PlaintextPanel,
7+
parameters: {
8+
layout: "centered",
9+
},
10+
title: "Components/PlaintextPanel",
11+
};
12+
13+
export default meta;
14+
type Story = StoryObj<typeof PlaintextPanel>;
15+
16+
const multilineLog = [
17+
'time=2026-06-11T19:03:14Z level=info msg="starting job" job_id=123',
18+
'time=2026-06-11T19:03:15Z level=info msg="processing batch" batch=1 records=100',
19+
'{"attempt":1,"event":"finished","metadata":{"customerID":"cus_123","queue":"default"}}',
20+
" indented continuation line",
21+
'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',
22+
].join("\n");
23+
24+
export const MultilineLog: Story = {
25+
args: {
26+
copyTitle: "Log Entry",
27+
text: multilineLog,
28+
},
29+
render: (args) => (
30+
<div className="w-[min(48rem,calc(100vw-2rem))]">
31+
<PlaintextPanel {...args} />
32+
</div>
33+
),
34+
};
35+
36+
export const WrappedExpression: Story = {
37+
args: {
38+
codeClassName: "whitespace-pre-wrap break-all",
39+
copyTitle: "Wait expression",
40+
text: [
41+
"all([",
42+
' task.completed("prepare-customer-state"),',
43+
' signal.received("manual-approval")',
44+
"])",
45+
].join("\n"),
46+
},
47+
render: (args) => (
48+
<div className="w-[min(32rem,calc(100vw-2rem))]">
49+
<PlaintextPanel {...args} />
50+
</div>
51+
),
52+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { act, fireEvent, render, screen } from "@testing-library/react";
2+
import toast from "react-hot-toast";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
import PlaintextPanel from "./PlaintextPanel";
6+
7+
Object.assign(navigator, {
8+
clipboard: {
9+
writeText: vi.fn().mockImplementation(() => Promise.resolve()),
10+
},
11+
});
12+
13+
vi.mock("react-hot-toast", () => ({
14+
default: {
15+
custom: vi.fn(),
16+
},
17+
}));
18+
19+
describe("PlaintextPanel", () => {
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
});
23+
24+
it("renders text in preformatted code markup", () => {
25+
const text = [
26+
"time=2026-06-11T19:03:14Z level=info msg=starting",
27+
" indented continuation",
28+
"time=2026-06-11T19:03:15Z level=info msg=finished",
29+
].join("\n");
30+
31+
const { container } = render(<PlaintextPanel text={text} />);
32+
33+
const pre = container.querySelector("pre");
34+
const code = pre?.querySelector("code");
35+
36+
expect(pre).toBeInTheDocument();
37+
expect(pre).toHaveClass("whitespace-pre");
38+
expect(code?.textContent).toBe(text);
39+
});
40+
41+
it("copies the original text", async () => {
42+
const text = "first line\nsecond line";
43+
44+
render(<PlaintextPanel copyTitle="Log Entry" text={text} />);
45+
46+
await act(async () => {
47+
fireEvent.click(screen.getByTestId("text-copy-button"));
48+
});
49+
50+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(text);
51+
expect(toast.custom).toHaveBeenCalled();
52+
});
53+
});

0 commit comments

Comments
 (0)