Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Attachment from "./Attachment";

Expand Down Expand Up @@ -199,6 +199,30 @@ describe("Attachment", () => {

expect(container.textContent).toContain("Expand to preview attachment");
});

it("should toggle between expanded and collapsed when the prompt is clicked", () => {
const { container } = render(
<Attachment
header={{ type: "attachment", content_type: "text/plain", filename: "test.txt" }}
attachment="Hello, World!"
/>,
);

// Collapsed by default: shows the prompt, no content decoded yet.
expect(container.textContent).toContain("Expand to preview attachment");
expect(container.textContent).not.toContain("Hello, World!");
expect(mockCreateObjectURL).not.toHaveBeenCalled();

// Expanding renders the content and builds the download URL on demand.
fireEvent.click(screen.getByRole("button", { name: /expand to preview attachment/i }));
expect(container.textContent).toContain("Hello, World!");
expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);

// Collapsing hides the content again.
fireEvent.click(screen.getByRole("button", { name: /collapse attachment/i }));
expect(container.textContent).toContain("Expand to preview attachment");
expect(container.textContent).not.toContain("Hello, World!");
});
});

describe("cleanup", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { EnvelopeItem } from "@sentry/core";
import { ReactComponent as Download } from "@spotlight/ui/assets/download.svg";
import { base64Decode, safeAtob } from "@spotlight/ui/lib/base64";
import { type ReactNode, useEffect, useMemo } from "react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import JsonViewer from "../../shared/JsonViewer";
import { CodeViewer } from "./CodeViewer";
import { inferExtension } from "./contentType";
Expand All @@ -23,10 +23,15 @@ export default function Attachment({
const extension = inferExtension(header.content_type as string | null, header.type as string | null);
const name = (header.filename as string) || `untitled.${extension}`;

// Lazily render the attachment: start from the `expanded` prop, but let the
// user expand it on demand so previews/downloads work wherever the component
// is used, without eagerly decoding every attachment.
const [isExpanded, setIsExpanded] = useState(expanded);

// Create download URL for attachment
// Returns: string (success), null (decode error), undefined (not expanded yet)
const downloadUrl = useMemo(() => {
if (!expanded) {
if (!isExpanded) {
return undefined; // Not needed yet
}

Expand Down Expand Up @@ -54,7 +59,7 @@ export default function Attachment({

const blob = new Blob([blobData], { type: contentType || "application/octet-stream" });
return URL.createObjectURL(blob);
}, [expanded, attachment, extension, header.content_type]);
}, [isExpanded, attachment, extension, header.content_type]);

// Cleanup blob URL on unmount or when URL changes
useEffect(() => {
Expand All @@ -69,7 +74,7 @@ export default function Attachment({

let content: ReactNode = null;

if (expanded) {
if (isExpanded) {
if (decodeError) {
content = (
<pre className="text-destructive-400 whitespace-pre-wrap break-words font-mono text-sm rounded-sm bg-primary-900 p-2">
Expand Down Expand Up @@ -114,7 +119,17 @@ export default function Attachment({
<Download className="inline h-4 w-4 opacity-60 transition-opacity group-hover:opacity-100" />
</a>
</h3>
{expanded ? content : <p className="text-primary-400 text-xs italic">Expand to preview attachment.</p>}
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => setIsExpanded(value => !value)}
aria-expanded={isExpanded}
className="text-primary-400 hover:text-primary-200 cursor-pointer text-left text-xs italic"
>
{isExpanded ? "Collapse attachment." : "Expand to preview attachment."}
</button>
{isExpanded ? content : null}
</div>
</>
);
}
Loading