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
136 changes: 136 additions & 0 deletions __tests__/pages/utilities/svg-viewer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import SVGViewer from "../../../pages/utilities/svg-viewer";

// Mock SVG content for testing
const mockSVGContent = `<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
</svg>`;

describe("SVGViewer", () => {
test("should render both paste and upload tabs", () => {
render(<SVGViewer />);

expect(
screen.getByRole("tab", { name: "Paste SVG Code" })
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "Upload SVG File" })
).toBeInTheDocument();
});

test("should display SVG code textarea in paste tab by default", () => {
render(<SVGViewer />);

expect(screen.getByLabelText("SVG code input")).toBeInTheDocument();
expect(
screen.getByPlaceholderText("Paste SVG code here")
).toBeInTheDocument();
});

test("should switch to upload tab and show upload component", async () => {
const user = userEvent.setup();
render(<SVGViewer />);

const uploadTab = screen.getByRole("tab", { name: "Upload SVG File" });
await user.click(uploadTab);

expect(
screen.getByText("Drag and drop your SVG file here, or click to select")
).toBeInTheDocument();
expect(screen.getByText("Max size 2MB")).toBeInTheDocument();
});

test("should accept and process SVG file upload", async () => {
const user = userEvent.setup();
render(<SVGViewer />);

// Switch to upload tab
const uploadTab = screen.getByRole("tab", { name: "Upload SVG File" });
await user.click(uploadTab);

// Create a mock SVG file
const file = new File([mockSVGContent], "test.svg", {
type: "image/svg+xml",
});

// Find the hidden file input
const fileInput = screen
.getByRole("tabpanel", { name: "Upload SVG File" })
.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).toBeInTheDocument();

// Upload the file
await user.upload(fileInput, file);

// Wait for the uploaded content to appear
await waitFor(() => {
expect(screen.getByLabelText("Uploaded SVG code")).toBeInTheDocument();
});

// Check that the SVG content is displayed in the textarea
const uploadedTextarea = screen.getByLabelText(
"Uploaded SVG code"
) as HTMLTextAreaElement;
expect(uploadedTextarea.value).toContain("<svg");
expect(uploadedTextarea.value).toContain("circle");
});

test("paste tab should work with manual input", async () => {
const user = userEvent.setup();
render(<SVGViewer />);

const textarea = screen.getByLabelText("SVG code input");
await user.type(textarea, mockSVGContent);

expect(textarea).toHaveValue(mockSVGContent);
});

test("should show error for invalid SVG content", async () => {
const user = userEvent.setup();
render(<SVGViewer />);

const textarea = screen.getByLabelText("SVG code input");
await user.type(textarea, "not an svg");

await waitFor(() => {
expect(
screen.getByText("Input does not contain an SVG tag")
).toBeInTheDocument();
});
});

test("uploaded content should be accessible in paste tab", async () => {
const user = userEvent.setup();
render(<SVGViewer />);

// Upload file in upload tab
const uploadTab = screen.getByRole("tab", { name: "Upload SVG File" });
await user.click(uploadTab);

const file = new File([mockSVGContent], "test.svg", {
type: "image/svg+xml",
});

const fileInput = screen
.getByRole("tabpanel", { name: "Upload SVG File" })
.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, file);

// Wait for upload to complete
await waitFor(() => {
expect(screen.getByLabelText("Uploaded SVG code")).toBeInTheDocument();
});

// Switch to paste tab
const pasteTab = screen.getByRole("tab", { name: "Paste SVG Code" });
await user.click(pasteTab);

// Check that the uploaded content is available in the paste tab
const pasteTextarea = screen.getByLabelText(
"SVG code input"
) as HTMLTextAreaElement;
expect(pasteTextarea.value).toContain("<svg");
expect(pasteTextarea.value).toContain("circle");
});
});
182 changes: 182 additions & 0 deletions components/ds/SVGUploadComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"use client";
import { DragEvent, useCallback, useMemo, useRef, useState } from "react";
import UploadIcon from "@/components/icons/UploadIcon";

type Status = "idle" | "loading" | "error" | "unsupported" | "hover";

const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB default for SVG files

interface SVGUploadProps {
onSVGSelect: (svgContent: string) => void;
maxFileSize?: number;
}

const SVGUploadComponent = ({
onSVGSelect,
maxFileSize = MAX_FILE_SIZE,
}: SVGUploadProps) => {
const [status, setStatus] = useState<Status>("idle");
const inputRef = useRef<HTMLInputElement>(null);

const formattedMaxSize = useMemo((): string => {
const sizeInMB = maxFileSize / (1024 * 1024);
return Number.isInteger(sizeInMB)
? `${sizeInMB}MB`
: `${sizeInMB.toFixed(2)}MB`;
}, [maxFileSize]);

const validateSVGFile = useCallback(
(file: File): boolean => {
// Check file extension
if (!file.name.toLowerCase().endsWith(".svg")) {
return false;
}

// Check file type
if (file.type && !file.type.includes("svg")) {
return false;
}

// Check file size
if (file.size > maxFileSize) {
return false;
}

return true;
},
[maxFileSize]
);

const readSVGFile = useCallback(
(file: File) => {
const reader = new FileReader();

reader.onload = (e) => {
try {
const content = e.target?.result as string;

// Basic SVG content validation
if (!content.toLowerCase().includes("<svg")) {
setStatus("unsupported");
return;
}

onSVGSelect(content);
setStatus("idle");
} catch (error) {
console.error("Error reading SVG file:", error);
setStatus("error");
}
};

reader.onerror = () => {
setStatus("error");
};

reader.readAsText(file);
},
[onSVGSelect]
);

const handleDrop = useCallback(
(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();

const file = event.dataTransfer.files[0];
if (!file) {
setStatus("unsupported");
return;
}

if (!validateSVGFile(file)) {
setStatus(file.size > maxFileSize ? "error" : "unsupported");
return;
}

setStatus("loading");
readSVGFile(file);
},
[validateSVGFile, readSVGFile, maxFileSize]
);

const handleDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setStatus("hover");
},
[]
);

const handleDragLeave = useCallback(() => {
setStatus("idle");
}, []);

const handleClick = () => {
inputRef.current?.click();
};

const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!validateSVGFile(file)) {
setStatus(file.size > maxFileSize ? "error" : "unsupported");
return;
}

setStatus("loading");
readSVGFile(file);
}
};

return (
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={handleClick}
className="flex flex-col border border-dashed border-border p-6 text-center text-muted-foreground rounded-lg min-h-40 items-center justify-center bg-muted cursor-pointer mb-2"
>
<input
ref={inputRef}
type="file"
accept=".svg,image/svg+xml"
className="hidden"
onChange={handleFileChange}
/>
<UploadIcon />
{statusComponents[status](formattedMaxSize)}
</div>
);
};

const StatusComponent = ({
title,
message,
}: {
title: string;
message?: string;
}) => (
<div>
<p>{title}</p>
<p>{message || "\u00A0"}</p>
</div>
);

const statusComponents: Record<Status, (maxSize: string) => JSX.Element> = {
idle: (maxSize) => (
<StatusComponent
title="Drag and drop your SVG file here, or click to select"
message={`Max size ${maxSize}`}
/>
),
loading: () => <StatusComponent title="Loading..." />,
error: (maxSize) => (
<StatusComponent title="SVG file is too big!" message={`${maxSize} max`} />
),
unsupported: () => (
<StatusComponent title="Please provide a valid SVG file" />
),
hover: () => <StatusComponent title="Drop it like it's hot! 🔥" />,
};

export { SVGUploadComponent };
Loading