Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
111 changes: 111 additions & 0 deletions src/components/CopyablePanel.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<ToastContentSuccess
message={`${copyTitle} copied to clipboard`}
t={t}
/>
));
setTimeout(() => {
setIsCopied(false);
}, 2000);
},
(err) => {
console.error("Failed to copy text: ", err);
},
);
};

return (
<div
className={`${styleConfig.container.base} ${className || ""}`}
style={styleConfig.container.font}
>
<div
className={styleConfig.header.base}
style={styleConfig.header.textAlign}
>
<button
className="inline-flex cursor-pointer items-center rounded p-1"
data-testid="text-copy-button"
onClick={copyToClipboard}
tabIndex={0}
title="Copy to clipboard"
type="button"
>
{isCopied ? (
<CheckIcon
aria-hidden="true"
className={`${styleConfig.icon.base} ${styleConfig.icon.check}`}
/>
) : (
<ClipboardIcon
aria-hidden="true"
className={`${styleConfig.icon.base} ${styleConfig.icon.clipboard}`}
/>
)}
</button>
</div>
<div className={styleConfig.content.base}>{children}</div>
</div>
);
}
3 changes: 2 additions & 1 deletion src/components/JSONView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe("JSONView Component", () => {
};

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

// Check that key elements are in the document
expect(screen.getByText(/"name"/)).toBeInTheDocument();
Expand All @@ -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", () => {
Expand Down
14 changes: 8 additions & 6 deletions src/components/JSONView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,13 +60,15 @@ export default function JSONView({
);

return (
<PlaintextPanel
<CopyablePanel
className={className}
codeClassName="pl-6"
content={jsonContent}
copyText={JSON.stringify(sortedData, null, 2)}
copyTitle={copyTitle}
rawText={JSON.stringify(sortedData, null, 2)}
/>
>
<div className="block pl-6 text-slate-800 dark:text-slate-200">
{jsonContent}
</div>
</CopyablePanel>
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/components/JobAttempts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,9 @@ function AttemptRow({ attemptInfo }: { attemptInfo: AttemptInfo }) {
<div className="mt-2 space-y-2">
{attemptInfo.logs.map((log, idx) => (
<PlaintextPanel
content={log.log}
copyTitle="Log Entry"
key={idx}
text={log.log}
/>
))}
</div>
Expand Down
52 changes: 52 additions & 0 deletions src/components/PlaintextPanel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite";

import PlaintextPanel from "./PlaintextPanel";

const meta: Meta<typeof PlaintextPanel> = {
component: PlaintextPanel,
parameters: {
layout: "centered",
},
title: "Components/PlaintextPanel",
};

export default meta;
type Story = StoryObj<typeof PlaintextPanel>;

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) => (
<div className="w-[min(48rem,calc(100vw-2rem))]">
<PlaintextPanel {...args} />
</div>
),
};

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) => (
<div className="w-[min(32rem,calc(100vw-2rem))]">
<PlaintextPanel {...args} />
</div>
),
};
53 changes: 53 additions & 0 deletions src/components/PlaintextPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PlaintextPanel text={text} />);

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(<PlaintextPanel copyTitle="Log Entry" text={text} />);

await act(async () => {
fireEvent.click(screen.getByTestId("text-copy-button"));
});

expect(navigator.clipboard.writeText).toHaveBeenCalledWith(text);
expect(toast.custom).toHaveBeenCalled();
});
});
Loading
Loading