Compare commits
2 Commits
fix/pypi-p
...
b9b4334393
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9b4334393 | ||
|
|
961f6ff6b4 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
- Added reusable `DragDropUpload` component for artifact uploads (#8)
|
||||||
|
- Drag-and-drop file selection with visual feedback
|
||||||
|
- Click-to-browse fallback
|
||||||
|
- Multiple file upload support with queue management
|
||||||
|
- Real-time progress indicators with speed and ETA
|
||||||
|
- File type and size validation (configurable)
|
||||||
|
- Concurrent upload handling (configurable max concurrent)
|
||||||
|
- Automatic retry with exponential backoff for network errors
|
||||||
|
- Individual file status (pending, uploading, complete, failed)
|
||||||
|
- Retry and remove actions per file
|
||||||
|
- Auto-dismiss success messages after 5 seconds
|
||||||
|
- Integrated DragDropUpload into PackagePage replacing basic file input (#8)
|
||||||
|
- Added frontend testing infrastructure with Vitest and React Testing Library (#14)
|
||||||
|
- Configured Vitest for React/TypeScript with jsdom
|
||||||
|
- Added 24 unit tests for DragDropUpload component
|
||||||
|
- Tests cover: rendering, drag-drop events, file validation, upload queue, progress, errors
|
||||||
- Added download verification with `verify` and `verify_mode` query parameters (#26)
|
- Added download verification with `verify` and `verify_mode` query parameters (#26)
|
||||||
- `?verify=true&verify_mode=pre` - Pre-verification: verify before streaming (guaranteed no corrupt data)
|
- `?verify=true&verify_mode=pre` - Pre-verification: verify before streaming (guaranteed no corrupt data)
|
||||||
- `?verify=true&verify_mode=stream` - Streaming verification: verify while streaming (logs error if mismatch)
|
- `?verify=true&verify_mode=stream` - Streaming verification: verify while streaming (logs error if mismatch)
|
||||||
|
|||||||
4451
frontend/package-lock.json
generated
Normal file
4451
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -14,10 +17,15 @@
|
|||||||
"react-router-dom": "^6.21.3"
|
"react-router-dom": "^6.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
|
"@testing-library/react": "^14.2.1",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"jsdom": "^24.0.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.0.12"
|
"vite": "^5.0.12",
|
||||||
|
"vitest": "^1.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
281
frontend/src/components/DragDropUpload.css
Normal file
281
frontend/src/components/DragDropUpload.css
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
.drag-drop-upload {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop Zone */
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--border-color, #ddd);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: var(--bg-secondary, #f9f9f9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone:hover {
|
||||||
|
border-color: var(--accent-color, #007bff);
|
||||||
|
background: var(--bg-hover, #f0f7ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone--active {
|
||||||
|
border-color: var(--accent-color, #007bff);
|
||||||
|
background: var(--bg-active, #e6f0ff);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone__input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone__content svg {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone--active .drop-zone__content svg {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--accent-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone__text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone__text strong {
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Queue */
|
||||||
|
.upload-queue {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-queue__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-secondary, #f9f9f9);
|
||||||
|
border-bottom: 1px solid var(--border-color, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-queue__title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-queue__clear {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent-color, #007bff);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-queue__clear:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-queue__overall {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-secondary, #f9f9f9);
|
||||||
|
border-bottom: 1px solid var(--border-color, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-queue__overall .progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-queue__overall .progress-bar__text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
min-width: 3rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-queue__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Item */
|
||||||
|
.upload-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color-light, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item--complete .upload-item__icon {
|
||||||
|
color: var(--success-color, #28a745);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item--failed .upload-item__icon {
|
||||||
|
color: var(--error-color, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item--uploading .upload-item__icon {
|
||||||
|
color: var(--accent-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__size {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__speed,
|
||||||
|
.upload-item__eta {
|
||||||
|
color: var(--accent-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__artifact {
|
||||||
|
color: var(--success-color, #28a745);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__error {
|
||||||
|
color: var(--error-color, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__retry-count {
|
||||||
|
color: var(--warning-color, #ffc107);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__btn:hover {
|
||||||
|
background: var(--bg-hover, #f0f0f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__btn--retry:hover {
|
||||||
|
color: var(--accent-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__btn--remove:hover {
|
||||||
|
color: var(--error-color, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--border-color, #ddd);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar--small {
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar__fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-color, #007bff);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item--complete .progress-bar__fill {
|
||||||
|
background: var(--success-color, #28a745);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.drop-zone {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__meta {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item__speed,
|
||||||
|
.upload-item__eta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
545
frontend/src/components/DragDropUpload.test.tsx
Normal file
545
frontend/src/components/DragDropUpload.test.tsx
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { DragDropUpload } from './DragDropUpload';
|
||||||
|
|
||||||
|
function createMockFile(name: string, size: number, type: string): File {
|
||||||
|
const content = new Array(size).fill('a').join('');
|
||||||
|
return new File([content], name, { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockXHR(options: {
|
||||||
|
status?: number;
|
||||||
|
response?: object;
|
||||||
|
progressEvents?: { loaded: number; total: number }[];
|
||||||
|
shouldError?: boolean;
|
||||||
|
shouldTimeout?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
const {
|
||||||
|
status = 200,
|
||||||
|
response = { artifact_id: 'abc123', size: 100 },
|
||||||
|
progressEvents = [],
|
||||||
|
shouldError = false,
|
||||||
|
shouldTimeout = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return class MockXHR {
|
||||||
|
status = status;
|
||||||
|
responseText = JSON.stringify(response);
|
||||||
|
timeout = 0;
|
||||||
|
upload = {
|
||||||
|
addEventListener: vi.fn((event: string, handler: (e: ProgressEvent) => void) => {
|
||||||
|
if (event === 'progress') {
|
||||||
|
progressEvents.forEach((p, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
handler({ lengthComputable: true, loaded: p.loaded, total: p.total } as ProgressEvent);
|
||||||
|
}, i * 10);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
addEventListener = vi.fn((event: string, handler: () => void) => {
|
||||||
|
if (event === 'load' && !shouldError && !shouldTimeout) {
|
||||||
|
setTimeout(handler, progressEvents.length * 10 + 10);
|
||||||
|
}
|
||||||
|
if (event === 'error' && shouldError) {
|
||||||
|
setTimeout(handler, 10);
|
||||||
|
}
|
||||||
|
if (event === 'timeout' && shouldTimeout) {
|
||||||
|
setTimeout(handler, 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
open = vi.fn();
|
||||||
|
send = vi.fn();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DragDropUpload', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
projectName: 'test-project',
|
||||||
|
packageName: 'test-package',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders drop zone with instructional text', () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/drag files here/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/click to browse/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hidden file input', () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]');
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
expect(input).toHaveClass('drop-zone__input');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows max file size hint when provided', () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} maxFileSize={1024 * 1024} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/max file size: 1 mb/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows allowed types hint when provided', () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} allowedTypes={['.zip', '.tar.gz']} allowAllTypes={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/\.zip, \.tar\.gz/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Click to Browse', () => {
|
||||||
|
it('opens file picker when drop zone is clicked', async () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const clickSpy = vi.spyOn(input, 'click');
|
||||||
|
|
||||||
|
const dropZone = screen.getByRole('button');
|
||||||
|
await userEvent.click(dropZone);
|
||||||
|
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens file picker on Enter key', () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const clickSpy = vi.spyOn(input, 'click');
|
||||||
|
|
||||||
|
const dropZone = screen.getByRole('button');
|
||||||
|
fireEvent.keyDown(dropZone, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Drag and Drop Events', () => {
|
||||||
|
it('shows visual feedback on drag over', () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const dropZone = screen.getByRole('button');
|
||||||
|
|
||||||
|
fireEvent.dragEnter(dropZone, {
|
||||||
|
dataTransfer: { items: [{}] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dropZone).toHaveClass('drop-zone--active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes visual feedback on drag leave', () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const dropZone = screen.getByRole('button');
|
||||||
|
|
||||||
|
fireEvent.dragEnter(dropZone, { dataTransfer: { items: [{}] } });
|
||||||
|
expect(dropZone).toHaveClass('drop-zone--active');
|
||||||
|
|
||||||
|
fireEvent.dragLeave(dropZone);
|
||||||
|
expect(dropZone).not.toHaveClass('drop-zone--active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts dropped files', async () => {
|
||||||
|
const MockXHR = createMockXHR();
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const dropZone = screen.getByRole('button');
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
Object.defineProperty(dataTransfer, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.drop(dropZone, { dataTransfer });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File Validation', () => {
|
||||||
|
it('rejects files exceeding max size', async () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} maxFileSize={100} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('large.txt', 200, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/exceeds.*limit/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects files with invalid type when allowAllTypes is false', async () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} allowedTypes={['.zip']} allowAllTypes={false} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/not allowed/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty files', async () => {
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('empty.txt', 0, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/empty file/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid files when allowAllTypes is true', async () => {
|
||||||
|
const MockXHR = createMockXHR();
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} allowAllTypes={true} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/not allowed/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Upload Queue', () => {
|
||||||
|
it('shows file in queue after selection', async () => {
|
||||||
|
const MockXHR = createMockXHR();
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('document.pdf', 1024, 'application/pdf');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('document.pdf')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1 KB')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple files', async () => {
|
||||||
|
const MockXHR = createMockXHR();
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const files = [
|
||||||
|
createMockFile('file1.txt', 100, 'text/plain'),
|
||||||
|
createMockFile('file2.txt', 200, 'text/plain'),
|
||||||
|
createMockFile('file3.txt', 300, 'text/plain'),
|
||||||
|
];
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign(files, { item: (i: number) => files[i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('file1.txt')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('file2.txt')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('file3.txt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows overall progress for multiple files', async () => {
|
||||||
|
const MockXHR = createMockXHR();
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const files = [
|
||||||
|
createMockFile('file1.txt', 100, 'text/plain'),
|
||||||
|
createMockFile('file2.txt', 100, 'text/plain'),
|
||||||
|
];
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign(files, { item: (i: number) => files[i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/uploading.*of.*files/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Upload Progress', () => {
|
||||||
|
it('shows progress bar during upload', async () => {
|
||||||
|
const MockXHR = createMockXHR({
|
||||||
|
progressEvents: [
|
||||||
|
{ loaded: 50, total: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const progressBar = document.querySelector('.progress-bar__fill');
|
||||||
|
expect(progressBar).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Upload Completion', () => {
|
||||||
|
it('shows success state when upload completes', async () => {
|
||||||
|
const MockXHR = createMockXHR({
|
||||||
|
response: { artifact_id: 'abc123def456', size: 100 },
|
||||||
|
});
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
render(<DragDropUpload {...defaultProps} onUploadComplete={onComplete} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/abc123def456/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onUploadComplete callback with results', async () => {
|
||||||
|
const MockXHR = createMockXHR({
|
||||||
|
response: { artifact_id: 'test-artifact-id', size: 100 },
|
||||||
|
});
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
render(<DragDropUpload {...defaultProps} onUploadComplete={onComplete} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onComplete).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ artifact_id: 'test-artifact-id' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Upload Errors', () => {
|
||||||
|
it('shows error state when upload fails after retries exhausted', async () => {
|
||||||
|
const MockXHR = createMockXHR({
|
||||||
|
status: 500,
|
||||||
|
response: { detail: 'Server error' },
|
||||||
|
shouldError: true,
|
||||||
|
});
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} maxRetries={0} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onUploadError callback when retries exhausted', async () => {
|
||||||
|
const MockXHR = createMockXHR({ shouldError: true });
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
const onError = vi.fn();
|
||||||
|
render(<DragDropUpload {...defaultProps} maxRetries={0} onUploadError={onError} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Queue Actions', () => {
|
||||||
|
it('removes item from queue when remove button clicked', async () => {
|
||||||
|
const MockXHR = createMockXHR();
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeButton = screen.getByTitle('Remove');
|
||||||
|
fireEvent.click(removeButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('test.txt')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears completed items when clear button clicked', async () => {
|
||||||
|
const MockXHR = createMockXHR();
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const clearButton = screen.queryByText(/clear finished/i);
|
||||||
|
if (clearButton) {
|
||||||
|
fireEvent.click(clearButton);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tag Support', () => {
|
||||||
|
it('includes tag in upload request', async () => {
|
||||||
|
let capturedFormData: FormData | null = null;
|
||||||
|
|
||||||
|
class MockXHR {
|
||||||
|
status = 200;
|
||||||
|
responseText = JSON.stringify({ artifact_id: 'abc123', size: 100 });
|
||||||
|
timeout = 0;
|
||||||
|
upload = { addEventListener: vi.fn() };
|
||||||
|
addEventListener = vi.fn((event: string, handler: () => void) => {
|
||||||
|
if (event === 'load') setTimeout(handler, 10);
|
||||||
|
});
|
||||||
|
open = vi.fn();
|
||||||
|
send = vi.fn((data: FormData) => {
|
||||||
|
capturedFormData = data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vi.stubGlobal('XMLHttpRequest', MockXHR);
|
||||||
|
|
||||||
|
render(<DragDropUpload {...defaultProps} tag="v1.0.0" />);
|
||||||
|
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
const file = createMockFile('test.txt', 100, 'text/plain');
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: Object.assign([file], { item: (i: number) => [file][i] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(capturedFormData?.get('tag')).toBe('v1.0.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
569
frontend/src/components/DragDropUpload.tsx
Normal file
569
frontend/src/components/DragDropUpload.tsx
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import './DragDropUpload.css';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type UploadStatus = 'pending' | 'uploading' | 'complete' | 'failed' | 'validating';
|
||||||
|
|
||||||
|
export interface UploadItem {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
status: UploadStatus;
|
||||||
|
progress: number;
|
||||||
|
speed: number; // bytes per second
|
||||||
|
error?: string;
|
||||||
|
artifactId?: string;
|
||||||
|
retryCount: number;
|
||||||
|
startTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
artifact_id: string;
|
||||||
|
size: number;
|
||||||
|
deduplicated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DragDropUploadProps {
|
||||||
|
projectName: string;
|
||||||
|
packageName: string;
|
||||||
|
onUploadComplete?: (results: UploadResult[]) => void;
|
||||||
|
onUploadError?: (error: string) => void;
|
||||||
|
allowedTypes?: string[]; // e.g., ['.tar.gz', '.zip', '.deb']
|
||||||
|
allowAllTypes?: boolean;
|
||||||
|
maxFileSize?: number; // in bytes
|
||||||
|
maxConcurrentUploads?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
tag?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(bytesPerSecond: number): string {
|
||||||
|
return `${formatBytes(bytesPerSecond)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeRemaining(seconds: number): string {
|
||||||
|
if (!isFinite(seconds) || seconds < 0) return '--:--';
|
||||||
|
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
|
||||||
|
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileExtension(filename: string): string {
|
||||||
|
// Handle compound extensions like .tar.gz
|
||||||
|
const parts = filename.toLowerCase().split('.');
|
||||||
|
if (parts.length >= 3 && parts[parts.length - 2] === 'tar') {
|
||||||
|
return `.${parts.slice(-2).join('.')}`;
|
||||||
|
}
|
||||||
|
return parts.length > 1 ? `.${parts[parts.length - 1]}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
function UploadIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RetryIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="23 4 23 10 17 10" />
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RemoveIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DragDropUpload({
|
||||||
|
projectName,
|
||||||
|
packageName,
|
||||||
|
onUploadComplete,
|
||||||
|
onUploadError,
|
||||||
|
allowedTypes,
|
||||||
|
allowAllTypes = true,
|
||||||
|
maxFileSize,
|
||||||
|
maxConcurrentUploads = 3,
|
||||||
|
maxRetries = 3,
|
||||||
|
tag,
|
||||||
|
className = '',
|
||||||
|
}: DragDropUploadProps) {
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [uploadQueue, setUploadQueue] = useState<UploadItem[]>([]);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dragCounterRef = useRef(0);
|
||||||
|
const activeUploadsRef = useRef(0);
|
||||||
|
|
||||||
|
// Validate a single file
|
||||||
|
const validateFile = useCallback((file: File): string | null => {
|
||||||
|
// Check file size
|
||||||
|
if (maxFileSize && file.size > maxFileSize) {
|
||||||
|
return `File exceeds ${formatBytes(maxFileSize)} limit`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file type if not allowing all types
|
||||||
|
if (!allowAllTypes && allowedTypes && allowedTypes.length > 0) {
|
||||||
|
const ext = getFileExtension(file.name);
|
||||||
|
if (!allowedTypes.some(t => t.toLowerCase() === ext)) {
|
||||||
|
return `File type ${ext || 'unknown'} not allowed. Accepted: ${allowedTypes.join(', ')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for empty file
|
||||||
|
if (file.size === 0) {
|
||||||
|
return 'Cannot upload empty file';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [allowedTypes, allowAllTypes, maxFileSize]);
|
||||||
|
|
||||||
|
// Add files to queue
|
||||||
|
const addFiles = useCallback((files: FileList | File[]) => {
|
||||||
|
const newItems: UploadItem[] = Array.from(files).map(file => {
|
||||||
|
const validationError = validateFile(file);
|
||||||
|
return {
|
||||||
|
id: generateId(),
|
||||||
|
file,
|
||||||
|
status: validationError ? 'failed' : 'pending',
|
||||||
|
progress: 0,
|
||||||
|
speed: 0,
|
||||||
|
error: validationError || undefined,
|
||||||
|
retryCount: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setUploadQueue(prev => [...prev, ...newItems]);
|
||||||
|
}, [validateFile]);
|
||||||
|
|
||||||
|
// Upload a single file with progress tracking
|
||||||
|
const uploadFile = useCallback((item: UploadItem): Promise<UploadResult> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', item.file);
|
||||||
|
if (tag) {
|
||||||
|
formData.append('tag', tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastLoaded = 0;
|
||||||
|
let lastTime = Date.now();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDiff = (now - lastTime) / 1000;
|
||||||
|
const loadedDiff = e.loaded - lastLoaded;
|
||||||
|
|
||||||
|
const speed = timeDiff > 0 ? loadedDiff / timeDiff : 0;
|
||||||
|
const progress = Math.round((e.loaded / e.total) * 100);
|
||||||
|
|
||||||
|
setUploadQueue(prev => prev.map(u =>
|
||||||
|
u.id === item.id
|
||||||
|
? { ...u, progress, speed, status: 'uploading' as UploadStatus }
|
||||||
|
: u
|
||||||
|
));
|
||||||
|
|
||||||
|
lastLoaded = e.loaded;
|
||||||
|
lastTime = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(xhr.responseText) as UploadResult;
|
||||||
|
resolve(result);
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Invalid response from server'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(xhr.responseText);
|
||||||
|
reject(new Error(error.detail || `Upload failed: ${xhr.status}`));
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`Upload failed: ${xhr.status}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
reject(new Error('Network error - check your connection'));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('timeout', () => {
|
||||||
|
reject(new Error('Upload timed out'));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/v1/project/${projectName}/${packageName}/upload`);
|
||||||
|
xhr.timeout = 300000; // 5 minute timeout
|
||||||
|
xhr.send(formData);
|
||||||
|
|
||||||
|
// Store xhr for potential cancellation
|
||||||
|
setUploadQueue(prev => prev.map(u =>
|
||||||
|
u.id === item.id
|
||||||
|
? { ...u, status: 'uploading' as UploadStatus, startTime: Date.now() }
|
||||||
|
: u
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}, [projectName, packageName, tag]);
|
||||||
|
|
||||||
|
// Process upload queue
|
||||||
|
const processQueue = useCallback(async () => {
|
||||||
|
const pendingItems = uploadQueue.filter(item => item.status === 'pending');
|
||||||
|
|
||||||
|
for (const item of pendingItems) {
|
||||||
|
if (activeUploadsRef.current >= maxConcurrentUploads) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeUploadsRef.current++;
|
||||||
|
|
||||||
|
// Start upload
|
||||||
|
setUploadQueue(prev => prev.map(u =>
|
||||||
|
u.id === item.id ? { ...u, status: 'uploading' as UploadStatus } : u
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadFile(item);
|
||||||
|
|
||||||
|
setUploadQueue(prev => prev.map(u =>
|
||||||
|
u.id === item.id
|
||||||
|
? { ...u, status: 'complete' as UploadStatus, progress: 100, artifactId: result.artifact_id }
|
||||||
|
: u
|
||||||
|
));
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Upload failed';
|
||||||
|
const shouldRetry = item.retryCount < maxRetries &&
|
||||||
|
(errorMessage.includes('Network') || errorMessage.includes('timeout'));
|
||||||
|
|
||||||
|
if (shouldRetry) {
|
||||||
|
// Exponential backoff retry
|
||||||
|
const delay = Math.pow(2, item.retryCount) * 1000;
|
||||||
|
setTimeout(() => {
|
||||||
|
setUploadQueue(prev => prev.map(u =>
|
||||||
|
u.id === item.id
|
||||||
|
? { ...u, status: 'pending' as UploadStatus, retryCount: u.retryCount + 1, progress: 0 }
|
||||||
|
: u
|
||||||
|
));
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
setUploadQueue(prev => prev.map(u =>
|
||||||
|
u.id === item.id
|
||||||
|
? { ...u, status: 'failed' as UploadStatus, error: errorMessage }
|
||||||
|
: u
|
||||||
|
));
|
||||||
|
onUploadError?.(errorMessage);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeUploadsRef.current--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [uploadQueue, maxConcurrentUploads, maxRetries, uploadFile, onUploadError]);
|
||||||
|
|
||||||
|
// Process queue when items are added or status changes
|
||||||
|
useEffect(() => {
|
||||||
|
const hasPending = uploadQueue.some(item => item.status === 'pending');
|
||||||
|
if (hasPending && activeUploadsRef.current < maxConcurrentUploads) {
|
||||||
|
processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify when all uploads complete
|
||||||
|
const allComplete = uploadQueue.length > 0 &&
|
||||||
|
uploadQueue.every(item => item.status === 'complete' || item.status === 'failed');
|
||||||
|
|
||||||
|
if (allComplete) {
|
||||||
|
const completedResults = uploadQueue
|
||||||
|
.filter(item => item.status === 'complete' && item.artifactId)
|
||||||
|
.map(item => ({
|
||||||
|
artifact_id: item.artifactId!,
|
||||||
|
size: item.file.size,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (completedResults.length > 0) {
|
||||||
|
onUploadComplete?.(completedResults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [uploadQueue, maxConcurrentUploads, processQueue, onUploadComplete]);
|
||||||
|
|
||||||
|
// Drag event handlers
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCounterRef.current++;
|
||||||
|
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||||
|
setIsDragOver(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCounterRef.current--;
|
||||||
|
if (dragCounterRef.current === 0) {
|
||||||
|
setIsDragOver(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
addFiles(files);
|
||||||
|
}
|
||||||
|
}, [addFiles]);
|
||||||
|
|
||||||
|
// Click to browse
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
addFiles(files);
|
||||||
|
}
|
||||||
|
// Reset input so same file can be selected again
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}, [addFiles]);
|
||||||
|
|
||||||
|
// Remove item from queue
|
||||||
|
const removeItem = useCallback((id: string) => {
|
||||||
|
setUploadQueue(prev => prev.filter(item => item.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Retry failed upload
|
||||||
|
const retryItem = useCallback((id: string) => {
|
||||||
|
setUploadQueue(prev => prev.map(item =>
|
||||||
|
item.id === id
|
||||||
|
? { ...item, status: 'pending' as UploadStatus, error: undefined, progress: 0, retryCount: 0 }
|
||||||
|
: item
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear completed/failed items
|
||||||
|
const clearCompleted = useCallback(() => {
|
||||||
|
setUploadQueue(prev => prev.filter(item =>
|
||||||
|
item.status !== 'complete' && item.status !== 'failed'
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Calculate overall progress
|
||||||
|
const overallProgress = uploadQueue.length > 0
|
||||||
|
? Math.round(uploadQueue.reduce((sum, item) => sum + item.progress, 0) / uploadQueue.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const completedCount = uploadQueue.filter(item => item.status === 'complete').length;
|
||||||
|
const failedCount = uploadQueue.filter(item => item.status === 'failed').length;
|
||||||
|
const uploadingCount = uploadQueue.filter(item => item.status === 'uploading').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`drag-drop-upload ${className}`}>
|
||||||
|
{/* Drop Zone */}
|
||||||
|
<div
|
||||||
|
className={`drop-zone ${isDragOver ? 'drop-zone--active' : ''}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={handleClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="drop-zone__input"
|
||||||
|
accept={!allowAllTypes && allowedTypes ? allowedTypes.join(',') : undefined}
|
||||||
|
/>
|
||||||
|
<div className="drop-zone__content">
|
||||||
|
<UploadIcon />
|
||||||
|
<p className="drop-zone__text">
|
||||||
|
<strong>Drag files here</strong> or click to browse
|
||||||
|
</p>
|
||||||
|
<p className="drop-zone__hint">
|
||||||
|
{maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`}
|
||||||
|
{!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Queue */}
|
||||||
|
{uploadQueue.length > 0 && (
|
||||||
|
<div className="upload-queue">
|
||||||
|
<div className="upload-queue__header">
|
||||||
|
<span className="upload-queue__title">
|
||||||
|
{uploadingCount > 0
|
||||||
|
? `Uploading ${uploadingCount} of ${uploadQueue.length} files`
|
||||||
|
: `${completedCount} of ${uploadQueue.length} files uploaded`
|
||||||
|
}
|
||||||
|
{failedCount > 0 && ` (${failedCount} failed)`}
|
||||||
|
</span>
|
||||||
|
{(completedCount > 0 || failedCount > 0) && (
|
||||||
|
<button
|
||||||
|
className="upload-queue__clear"
|
||||||
|
onClick={clearCompleted}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear finished
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall progress bar */}
|
||||||
|
{uploadingCount > 0 && (
|
||||||
|
<div className="upload-queue__overall">
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-bar__fill"
|
||||||
|
style={{ width: `${overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="progress-bar__text">{overallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Individual file items */}
|
||||||
|
<ul className="upload-queue__list">
|
||||||
|
{uploadQueue.map(item => (
|
||||||
|
<li key={item.id} className={`upload-item upload-item--${item.status}`}>
|
||||||
|
<div className="upload-item__icon">
|
||||||
|
{item.status === 'complete' ? <CheckIcon /> :
|
||||||
|
item.status === 'failed' ? <ErrorIcon /> :
|
||||||
|
<FileIcon />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="upload-item__info">
|
||||||
|
<div className="upload-item__name" title={item.file.name}>
|
||||||
|
{item.file.name}
|
||||||
|
</div>
|
||||||
|
<div className="upload-item__meta">
|
||||||
|
<span className="upload-item__size">{formatBytes(item.file.size)}</span>
|
||||||
|
{item.status === 'uploading' && item.speed > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="upload-item__speed">{formatSpeed(item.speed)}</span>
|
||||||
|
{item.startTime && (
|
||||||
|
<span className="upload-item__eta">
|
||||||
|
{formatTimeRemaining(
|
||||||
|
(item.file.size - (item.file.size * item.progress / 100)) / item.speed
|
||||||
|
)} remaining
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.status === 'complete' && item.artifactId && (
|
||||||
|
<span className="upload-item__artifact">
|
||||||
|
ID: {item.artifactId.substring(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.error && (
|
||||||
|
<span className="upload-item__error">{item.error}</span>
|
||||||
|
)}
|
||||||
|
{item.retryCount > 0 && item.status === 'uploading' && (
|
||||||
|
<span className="upload-item__retry-count">Retry {item.retryCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.status === 'uploading' && (
|
||||||
|
<div className="progress-bar progress-bar--small">
|
||||||
|
<div
|
||||||
|
className="progress-bar__fill"
|
||||||
|
style={{ width: `${item.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="upload-item__actions">
|
||||||
|
{item.status === 'failed' && (
|
||||||
|
<button
|
||||||
|
className="upload-item__btn upload-item__btn--retry"
|
||||||
|
onClick={() => retryItem(item.id)}
|
||||||
|
title="Retry upload"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RetryIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(item.status === 'complete' || item.status === 'failed' || item.status === 'pending') && (
|
||||||
|
<button
|
||||||
|
className="upload-item__btn upload-item__btn--remove"
|
||||||
|
onClick={() => removeItem(item.id)}
|
||||||
|
title="Remove"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RemoveIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,3 +10,5 @@ export { FilterChip, FilterChipGroup } from './FilterChip';
|
|||||||
export { DataTable } from './DataTable';
|
export { DataTable } from './DataTable';
|
||||||
export { Pagination } from './Pagination';
|
export { Pagination } from './Pagination';
|
||||||
export { GlobalSearch } from './GlobalSearch';
|
export { GlobalSearch } from './GlobalSearch';
|
||||||
|
export { DragDropUpload } from './DragDropUpload';
|
||||||
|
export type { DragDropUploadProps, UploadItem, UploadResult, UploadStatus } from './DragDropUpload';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { TagDetail, Package, PaginatedResponse } from '../types';
|
import { TagDetail, Package, PaginatedResponse } from '../types';
|
||||||
import { listTags, uploadArtifact, getDownloadUrl, getPackage } from '../api';
|
import { listTags, getDownloadUrl, getPackage } from '../api';
|
||||||
import { Breadcrumb } from '../components/Breadcrumb';
|
import { Breadcrumb } from '../components/Breadcrumb';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { SearchInput } from '../components/SearchInput';
|
import { SearchInput } from '../components/SearchInput';
|
||||||
@@ -9,6 +9,7 @@ import { SortDropdown, SortOption } from '../components/SortDropdown';
|
|||||||
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
|
||||||
import { DataTable } from '../components/DataTable';
|
import { DataTable } from '../components/DataTable';
|
||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
|
import { DragDropUpload, UploadResult } from '../components/DragDropUpload';
|
||||||
import './Home.css';
|
import './Home.css';
|
||||||
import './PackagePage.css';
|
import './PackagePage.css';
|
||||||
|
|
||||||
@@ -61,10 +62,8 @@ function PackagePage() {
|
|||||||
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
|
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploadTag, setUploadTag] = useState('');
|
||||||
const [uploadResult, setUploadResult] = useState<string | null>(null);
|
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
||||||
const [tag, setTag] = useState('');
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Get params from URL
|
// Get params from URL
|
||||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
@@ -122,30 +121,22 @@ function PackagePage() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [navigate, projectName]);
|
}, [navigate, projectName]);
|
||||||
|
|
||||||
async function handleUpload(e: React.FormEvent) {
|
const handleUploadComplete = useCallback((results: UploadResult[]) => {
|
||||||
e.preventDefault();
|
const count = results.length;
|
||||||
const file = fileInputRef.current?.files?.[0];
|
const message = count === 1
|
||||||
if (!file) {
|
? `Uploaded successfully! Artifact ID: ${results[0].artifact_id}`
|
||||||
setError('Please select a file');
|
: `${count} files uploaded successfully!`;
|
||||||
return;
|
setUploadSuccess(message);
|
||||||
}
|
setUploadTag('');
|
||||||
|
|
||||||
try {
|
|
||||||
setUploading(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await uploadArtifact(projectName!, packageName!, file, tag || undefined);
|
|
||||||
setUploadResult(`Uploaded successfully! Artifact ID: ${result.artifact_id}`);
|
|
||||||
setTag('');
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
loadData();
|
loadData();
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
// Auto-dismiss success message after 5 seconds
|
||||||
} finally {
|
setTimeout(() => setUploadSuccess(null), 5000);
|
||||||
setUploading(false);
|
}, [loadData]);
|
||||||
}
|
|
||||||
}
|
const handleUploadError = useCallback((errorMsg: string) => {
|
||||||
|
setError(errorMsg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
updateParams({ search: value, page: '1' });
|
updateParams({ search: value, page: '1' });
|
||||||
@@ -292,29 +283,29 @@ function PackagePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
{uploadResult && <div className="success-message">{uploadResult}</div>}
|
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
|
||||||
|
|
||||||
<div className="upload-section card">
|
<div className="upload-section card">
|
||||||
<h3>Upload Artifact</h3>
|
<h3>Upload Artifact</h3>
|
||||||
<form onSubmit={handleUpload} className="upload-form">
|
<div className="upload-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="file">File</label>
|
<label htmlFor="upload-tag">Tag (optional)</label>
|
||||||
<input id="file" type="file" ref={fileInputRef} required />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="tag">Tag (optional)</label>
|
|
||||||
<input
|
<input
|
||||||
id="tag"
|
id="upload-tag"
|
||||||
type="text"
|
type="text"
|
||||||
value={tag}
|
value={uploadTag}
|
||||||
onChange={(e) => setTag(e.target.value)}
|
onChange={(e) => setUploadTag(e.target.value)}
|
||||||
placeholder="v1.0.0, latest, stable..."
|
placeholder="v1.0.0, latest, stable..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-primary" disabled={uploading}>
|
<DragDropUpload
|
||||||
{uploading ? 'Uploading...' : 'Upload'}
|
projectName={projectName!}
|
||||||
</button>
|
packageName={packageName!}
|
||||||
</form>
|
tag={uploadTag || undefined}
|
||||||
|
onUploadComplete={handleUploadComplete}
|
||||||
|
onUploadError={handleUploadError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
|
|||||||
37
frontend/src/test/setup.ts
Normal file
37
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
class MockDataTransfer implements DataTransfer {
|
||||||
|
dropEffect: DataTransfer['dropEffect'] = 'none';
|
||||||
|
effectAllowed: DataTransfer['effectAllowed'] = 'all';
|
||||||
|
files: FileList = Object.assign([], { item: (i: number) => this.files[i] || null });
|
||||||
|
items: DataTransferItemList = Object.assign([], {
|
||||||
|
add: () => null,
|
||||||
|
remove: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
item: () => null,
|
||||||
|
}) as unknown as DataTransferItemList;
|
||||||
|
types: readonly string[] = [];
|
||||||
|
|
||||||
|
clearData(): void {}
|
||||||
|
getData(): string { return ''; }
|
||||||
|
setData(): void {}
|
||||||
|
setDragImage(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(global, 'DataTransfer', {
|
||||||
|
value: MockDataTransfer,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
@@ -9,5 +10,11 @@ export default defineConfig({
|
|||||||
'/health': 'http://localhost:8080',
|
'/health': 'http://localhost:8080',
|
||||||
'/project': 'http://localhost:8080',
|
'/project': 'http://localhost:8080',
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts',
|
||||||
|
css: true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user