diff --git a/frontend/src/components/UploadButton.tsx b/frontend/src/components/UploadButton.tsx new file mode 100644 index 0000000..98d58ae --- /dev/null +++ b/frontend/src/components/UploadButton.tsx @@ -0,0 +1,85 @@ +import { useRef, useState } from 'react'; +import { uploadDocument, type UploadDocumentResponse } from '../api/client'; + +interface UploadButtonProps { + path: string; + onUploaded: (result: UploadDocumentResponse) => void; +} + +export default function UploadButton({ path, onUploaded }: UploadButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + + function openModal() { + setIsOpen(true); + } + + function closeModal() { + setIsOpen(false); + setIsDragOver(false); + } + + function uploadFiles(files: FileList | File[]) { + Array.from(files).forEach((file) => { + uploadDocument(file, path).then((result) => { + onUploaded(result); + }); + }); + } + + function handleFileInputChange(event: React.ChangeEvent) { + if (event.target.files) { + uploadFiles(event.target.files); + } + event.target.value = ''; + } + + function handleDrop(event: React.DragEvent) { + event.preventDefault(); + setIsDragOver(false); + if (event.dataTransfer.files) { + uploadFiles(event.dataTransfer.files); + } + } + + function handleDragOver(event: React.DragEvent) { + event.preventDefault(); + setIsDragOver(true); + } + + function handleDragLeave() { + setIsDragOver(false); + } + + return ( + <> + + {isOpen && ( +
+
+
fileInputRef.current?.click()} + onDrop={handleDrop} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + > + 拖拽文件到此处,或点击选择 +
+ + +
+
+ )} + + ); +} diff --git a/frontend/tests/components/UploadButton.test.tsx b/frontend/tests/components/UploadButton.test.tsx new file mode 100644 index 0000000..3fe7145 --- /dev/null +++ b/frontend/tests/components/UploadButton.test.tsx @@ -0,0 +1,61 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, beforeEach } from 'vitest'; +import UploadButton from '../../src/components/UploadButton'; +import { uploadDocument } from '../../src/api/client'; + +vi.mock('../../src/api/client'); + +beforeEach(() => { + vi.mocked(uploadDocument).mockReset(); +}); + +test('opens the upload modal when the button is clicked', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByTestId('upload-modal')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: '上传文档' })); + + expect(screen.getByTestId('upload-modal')).toBeInTheDocument(); +}); + +test('selecting a file through the hidden input uploads it', async () => { + vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/新文档.md', name: '新文档.md', size: 42 }); + const onUploaded = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: '上传文档' })); + + const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' }); + const input = screen.getByTestId('upload-file-input'); + await user.upload(input, file); + + expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档'); + await waitFor(() => { + expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/新文档.md', name: '新文档.md', size: 42 }); + }); +}); + +test('dropping a file onto the dropzone uploads it', async () => { + vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 }); + const onUploaded = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: '上传文档' })); + + const file = new File(['内容'], '拖拽文档.md', { type: 'text/markdown' }); + const dropzone = screen.getByTestId('dropzone'); + + fireEvent.drop(dropzone, { + dataTransfer: { files: [file] }, + }); + + expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档'); + await waitFor(() => { + expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 }); + }); +});