feat: add UploadButton with click-to-select and drag-and-drop
This commit is contained in:
parent
10b2b575d9
commit
90f3784ffa
85
frontend/src/components/UploadButton.tsx
Normal file
85
frontend/src/components/UploadButton.tsx
Normal file
@ -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<HTMLInputElement>(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<HTMLInputElement>) {
|
||||||
|
if (event.target.files) {
|
||||||
|
uploadFiles(event.target.files);
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
if (event.dataTransfer.files) {
|
||||||
|
uploadFiles(event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: React.DragEvent<HTMLDivElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
setIsDragOver(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={openModal}>上传文档</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="modal-backdrop show" data-testid="upload-modal">
|
||||||
|
<div className="modal">
|
||||||
|
<div
|
||||||
|
className={isDragOver ? 'dropzone dragover' : 'dropzone'}
|
||||||
|
data-testid="dropzone"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
>
|
||||||
|
拖拽文件到此处,或点击选择
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
data-testid="upload-file-input"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
/>
|
||||||
|
<button onClick={closeModal}>取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/tests/components/UploadButton.test.tsx
Normal file
61
frontend/tests/components/UploadButton.test.tsx
Normal file
@ -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(<UploadButton path="产品文档" onUploaded={vi.fn()} />);
|
||||||
|
|
||||||
|
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(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||||
|
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(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user