-
Notifications
You must be signed in to change notification settings - Fork 264
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #338 from Peliah/feat/HNG-213-Component-MediaFileU…
…pload Feat/hng 213 component media file upload
- Loading branch information
Showing
2 changed files
with
221 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
"use client"; | ||
|
||
import { TrashIcon } from "lucide-react"; | ||
import Image from "next/image"; | ||
import { useCallback, useEffect, useRef, useState } from "react"; | ||
|
||
import { Button } from "../ui/button"; | ||
|
||
interface MediaUploadProperties { | ||
onFilesAdded: (files: File[]) => void; | ||
onFileDeleted: (file: File) => void; | ||
label: string; | ||
CustomButton?: React.FC<{ onClick: () => void }>; | ||
} | ||
|
||
const MediaUpload: React.FC<MediaUploadProperties> = ({ | ||
onFileDeleted, | ||
onFilesAdded, | ||
label, | ||
CustomButton, | ||
}) => { | ||
const [files, setFiles] = useState<File[]>([]); | ||
const fileInputReference = useRef<HTMLInputElement | null>(null); | ||
|
||
const handleDragOver = useCallback((event_: React.DragEvent) => { | ||
event_.preventDefault(); | ||
event_.stopPropagation(); | ||
}, []); | ||
|
||
const handleFilesAdded = useCallback( | ||
(newFiles: FileList) => { | ||
const fileArray = Array.prototype.slice.call(newFiles); | ||
setFiles((previousFiles) => [...previousFiles, ...fileArray]); | ||
onFilesAdded(fileArray); | ||
}, | ||
[onFilesAdded], | ||
); | ||
|
||
const handleDrop = useCallback( | ||
(event_: React.DragEvent) => { | ||
event_.preventDefault(); | ||
event_.stopPropagation(); | ||
const newFiles = event_.dataTransfer.files; | ||
handleFilesAdded(newFiles); | ||
}, | ||
[handleFilesAdded], | ||
); | ||
|
||
const handleDeleteFile = useCallback( | ||
(file: File) => { | ||
setFiles((previousFiles) => previousFiles.filter((f) => f !== file)); | ||
onFileDeleted(file); | ||
}, | ||
[onFileDeleted], | ||
); | ||
|
||
const handleButtonClick = useCallback(() => { | ||
if (fileInputReference.current) { | ||
fileInputReference.current.click(); | ||
} | ||
}, []); | ||
|
||
useEffect(() => { | ||
return () => { | ||
for (const file of files) URL.revokeObjectURL(URL.createObjectURL(file)); | ||
}; | ||
}, [files]); | ||
|
||
return ( | ||
<div> | ||
<div className="flex w-[443px] flex-col gap-2 p-2"> | ||
<h2 className="text-sm font-medium">Media</h2> | ||
{files.length === 0 ? ( | ||
<div className="rounded-[6px]"> | ||
<div | ||
className="flex h-[125px] w-full flex-col items-center justify-center gap-3 rounded-[6px] border border-dashed border-slate-300 bg-zinc-50" | ||
onDragOver={handleDragOver} | ||
onDrop={handleDrop} | ||
data-testid="drop-area" | ||
> | ||
<div className=""> | ||
<input | ||
type="file" | ||
name="file" | ||
id="file" | ||
className="inputfile absolute z-[-1] h-[0.1px] w-[0.1px] overflow-hidden opacity-0" | ||
data-multiple-caption="{count} files selected" | ||
multiple | ||
ref={fileInputReference} | ||
onChange={(event_) => | ||
event_.target.files && handleFilesAdded(event_.target.files) | ||
} | ||
/> | ||
{CustomButton ? ( | ||
<CustomButton onClick={handleButtonClick} /> | ||
) : ( | ||
<label | ||
htmlFor="file" | ||
className="inline-block cursor-pointer rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium leading-6 text-gray-900 shadow-[0px_1px_18px_0px_#0A39B01F]" | ||
> | ||
<span className="pointer-events-none">Upload New</span> | ||
</label> | ||
)} | ||
</div> | ||
<label className="text-sm font-normal text-zinc-600"> | ||
{label} | ||
</label> | ||
</div> | ||
</div> | ||
) : ( | ||
<div> | ||
{files.map((file, index) => ( | ||
<div | ||
key={index} | ||
className="image-container group relative mb-2 h-[125px] w-full" | ||
> | ||
{URL.createObjectURL && ( | ||
<Image | ||
src={URL.createObjectURL(file)} | ||
alt={file.name} | ||
width={125} | ||
height={125} | ||
className="rounded-[6px]" | ||
/> | ||
)} | ||
<div className="absolute left-0 top-0 h-full w-full rounded-[6px] bg-[rgba(10,10,10,0.75)] opacity-0 group-hover:opacity-100"></div> | ||
<Button | ||
variant={"outline"} | ||
onClick={() => handleDeleteFile(file)} | ||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform rounded-full bg-white p-2 text-white opacity-0 group-hover:opacity-100" | ||
aria-label="delete file" | ||
> | ||
<TrashIcon color="red" /> | ||
</Button> | ||
</div> | ||
))} | ||
</div> | ||
)} | ||
</div> | ||
</div> | ||
); | ||
}; | ||
|
||
export default MediaUpload; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; | ||
import { describe, expect, it, vi } from "vitest"; | ||
|
||
import MediaUpload from "../components/MediaUpload/index"; | ||
|
||
// Ensure global.URL.createObjectURL is defined | ||
if (!global.URL.createObjectURL) { | ||
global.URL.createObjectURL = () => `blob:http://localhost/test-blob`; | ||
} | ||
|
||
// Ensure global.URL.revokeObjectURL is defined | ||
if (!global.URL.revokeObjectURL) { | ||
global.URL.revokeObjectURL = () => {}; | ||
} | ||
|
||
describe("mediaUpload", () => { | ||
it("renders and allows files to be added", async () => { | ||
expect.hasAssertions(); | ||
const onFilesAdded = vi.fn(); | ||
const onFileDeleted = vi.fn(); | ||
|
||
render( | ||
<MediaUpload | ||
onFilesAdded={onFilesAdded} | ||
onFileDeleted={onFileDeleted} | ||
label="Upload your media" | ||
/>, | ||
); | ||
|
||
// Simulate file input change | ||
const fileInput = screen.getByLabelText(/upload new/i) as HTMLInputElement; | ||
const file = new File(["dummy content"], "example.png", { | ||
type: "image/png", | ||
}); | ||
fireEvent.change(fileInput, { target: { files: [file] } }); | ||
|
||
// Wait for files to be added and the component to update | ||
await waitFor(() => { | ||
expect(onFilesAdded).toHaveBeenCalledWith([file]); | ||
}); | ||
}); | ||
|
||
it("allows files to be deleted", async () => { | ||
expect.hasAssertions(); | ||
const onFilesAdded = vi.fn(); | ||
const onFileDeleted = vi.fn(); | ||
|
||
render( | ||
<MediaUpload | ||
onFilesAdded={onFilesAdded} | ||
onFileDeleted={onFileDeleted} | ||
label="Upload your media" | ||
/>, | ||
); | ||
|
||
// Simulate file input change | ||
const fileInput = screen.getByLabelText(/upload new/i) as HTMLInputElement; | ||
const file = new File(["dummy content"], "example.png", { | ||
type: "image/png", | ||
}); | ||
fireEvent.change(fileInput, { target: { files: [file] } }); | ||
|
||
// Wait for files to be added | ||
await waitFor(() => { | ||
expect(onFilesAdded).toHaveBeenCalledWith([file]); | ||
}); | ||
|
||
// Simulate delete button click outside of waitFor | ||
const deleteButton = screen.getByRole("button", { name: /delete file/i }); | ||
fireEvent.click(deleteButton); | ||
|
||
// Wait for file to be deleted and the component to update | ||
await waitFor(() => { | ||
expect(onFileDeleted).toHaveBeenCalledWith(file); | ||
}); | ||
}); | ||
}); |