+
Drop files to begin upload
-
-
-
diff --git a/src-ui/src/app/components/file-drop/file-drop.component.scss b/src-ui/src/app/components/file-drop/file-drop.component.scss
index 62636c4e4..29e6851ef 100644
--- a/src-ui/src/app/components/file-drop/file-drop.component.scss
+++ b/src-ui/src/app/components/file-drop/file-drop.component.scss
@@ -1,8 +1,14 @@
.global-dropzone-overlay {
+ opacity: 0;
+ transition: opacity 0.25s ease-in-out;
background-color: hsla(var(--pngx-primary), var(--pngx-primary-lightness), .8);
z-index: 1200;
h2 {
color: var(--pngx-primary-text-contrast)
}
+
+ &.active {
+ opacity: 1;
+ }
}
diff --git a/src-ui/src/app/components/file-drop/file-drop.component.spec.ts b/src-ui/src/app/components/file-drop/file-drop.component.spec.ts
index bd3a56a3f..78e024955 100644
--- a/src-ui/src/app/components/file-drop/file-drop.component.spec.ts
+++ b/src-ui/src/app/components/file-drop/file-drop.component.spec.ts
@@ -9,7 +9,6 @@ import {
tick,
} from '@angular/core/testing'
import { By } from '@angular/platform-browser'
-import { NgxFileDropEntry, NgxFileDropModule } from 'ngx-file-drop'
import { PermissionsService } from 'src/app/services/permissions.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -27,7 +26,7 @@ describe('FileDropComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
- imports: [NgxFileDropModule, FileDropComponent, ToastsComponent],
+ imports: [FileDropComponent, ToastsComponent],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
@@ -66,12 +65,12 @@ describe('FileDropComponent', () => {
const dropzone = fixture.debugElement.query(
By.css('.global-dropzone-overlay')
)
- expect(dropzone.classes['hide']).toBeTruthy()
+ expect(dropzone.classes['active']).toBeFalsy()
component.onDragLeave(new Event('dragleave') as DragEvent)
tick(700)
fixture.detectChanges()
// drop
- const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
+ const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFile')
const dragEvent = new Event('drop')
dragEvent['dataTransfer'] = {
files: {
@@ -93,53 +92,209 @@ describe('FileDropComponent', () => {
tick(1)
fixture.detectChanges()
expect(component.fileIsOver).toBeTruthy()
- const dropzone = fixture.debugElement.query(
- By.css('.global-dropzone-overlay')
- )
component.onDragLeave(new Event('dragleave') as DragEvent)
tick(700)
fixture.detectChanges()
- expect(dropzone.classes['hide']).toBeTruthy()
// drop
const toastSpy = jest.spyOn(toastService, 'show')
- const uploadSpy = jest.spyOn(
- UploadDocumentsService.prototype as any,
- 'uploadFile'
+ const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFile')
+ const file = new File(
+ [new Blob(['testing'], { type: 'application/pdf' })],
+ 'file.pdf'
)
const dragEvent = new Event('drop')
dragEvent['dataTransfer'] = {
- files: {
- item: () => {
- return new File(
- [new Blob(['testing'], { type: 'application/pdf' })],
- 'file.pdf'
- )
+ items: [
+ {
+ kind: 'file',
+ type: 'application/pdf',
+ getAsFile: () => file,
},
- length: 1,
- } as unknown as FileList,
+ ],
}
component.onDrop(dragEvent as DragEvent)
- component.dropped([
- {
- fileEntry: {
- isFile: true,
- file: (callback) => {
- callback(
- new File(
- [new Blob(['testing'], { type: 'application/pdf' })],
- 'file.pdf'
- )
- )
- },
- },
- } as unknown as NgxFileDropEntry,
- ])
tick(3000)
expect(toastSpy).toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled()
discardPeriodicTasks()
}))
+ it('should support drag drop, initiate upload with webkitGetAsEntry', fakeAsync(() => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ expect(component.fileIsOver).toBeFalsy()
+ const overEvent = new Event('dragover') as DragEvent
+ ;(overEvent as any).dataTransfer = { types: ['Files'] }
+ component.onDragOver(overEvent)
+ tick(1)
+ fixture.detectChanges()
+ expect(component.fileIsOver).toBeTruthy()
+ component.onDragLeave(new Event('dragleave') as DragEvent)
+ tick(700)
+ fixture.detectChanges()
+ // drop
+ const toastSpy = jest.spyOn(toastService, 'show')
+ const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFile')
+ const file = new File(
+ [new Blob(['testing'], { type: 'application/pdf' })],
+ 'file.pdf'
+ )
+ const dragEvent = new Event('drop')
+ dragEvent['dataTransfer'] = {
+ items: [
+ {
+ kind: 'file',
+ type: 'application/pdf',
+ webkitGetAsEntry: () => ({
+ isFile: true,
+ isDirectory: false,
+ file: (cb: (file: File) => void) => cb(file),
+ }),
+ },
+ ],
+ files: [],
+ }
+ component.onDrop(dragEvent as DragEvent)
+ tick(3000)
+ expect(toastSpy).toHaveBeenCalled()
+ expect(uploadSpy).toHaveBeenCalled()
+ discardPeriodicTasks()
+ }))
+
+ it('should show an error on traverseFileTree error', fakeAsync(() => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ const toastSpy = jest.spyOn(toastService, 'showError')
+ const traverseSpy = jest
+ .spyOn(component as any, 'traverseFileTree')
+ .mockReturnValue(Promise.reject(new Error('Error traversing file tree')))
+ fixture.detectChanges()
+
+ // Simulate a drop with a directory entry
+ const mockEntry = {
+ isDirectory: true,
+ isFile: false,
+ createReader: () => ({ readEntries: jest.fn() }),
+ } as unknown as FileSystemDirectoryEntry
+
+ const event = {
+ preventDefault: () => {},
+ stopImmediatePropagation: () => {},
+ dataTransfer: {
+ items: [
+ {
+ kind: 'file',
+ webkitGetAsEntry: () => mockEntry,
+ },
+ ],
+ },
+ } as unknown as DragEvent
+
+ component.onDrop(event)
+
+ tick() // flush microtasks (e.g., Promise.reject)
+
+ expect(traverseSpy).toHaveBeenCalled()
+ expect(toastSpy).toHaveBeenCalledWith(
+ $localize`Failed to read dropped items: Error traversing file tree`
+ )
+
+ discardPeriodicTasks()
+ }))
+
+ it('should support drag drop, initiate upload without DataTransfer API support', fakeAsync(() => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ expect(component.fileIsOver).toBeFalsy()
+ const overEvent = new Event('dragover') as DragEvent
+ ;(overEvent as any).dataTransfer = { types: ['Files'] }
+ component.onDragOver(overEvent)
+ tick(1)
+ fixture.detectChanges()
+ expect(component.fileIsOver).toBeTruthy()
+ component.onDragLeave(new Event('dragleave') as DragEvent)
+ tick(700)
+ fixture.detectChanges()
+ // drop
+ const toastSpy = jest.spyOn(toastService, 'show')
+ const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFile')
+ const file = new File(
+ [new Blob(['testing'], { type: 'application/pdf' })],
+ 'file.pdf'
+ )
+ const dragEvent = new Event('drop')
+ dragEvent['dataTransfer'] = {
+ items: [],
+ files: [file],
+ }
+ component.onDrop(dragEvent as DragEvent)
+ tick(3000)
+ expect(toastSpy).toHaveBeenCalled()
+ expect(uploadSpy).toHaveBeenCalled()
+ discardPeriodicTasks()
+ }))
+
+ it('should resolve a single file when entry isFile', () => {
+ const mockFile = new File(['data'], 'test.txt', { type: 'text/plain' })
+ const mockEntry = {
+ isFile: true,
+ isDirectory: false,
+ file: (cb: (f: File) => void) => cb(mockFile),
+ } as unknown as FileSystemFileEntry
+
+ return (component as any)
+ .traverseFileTree(mockEntry)
+ .then((result: File[]) => {
+ expect(result).toEqual([mockFile])
+ })
+ })
+
+ it('should resolve all files in a flat directory', async () => {
+ const file1 = new File(['data'], 'file1.txt')
+ const file2 = new File(['data'], 'file2.txt')
+
+ const mockFileEntry1 = {
+ isFile: true,
+ isDirectory: false,
+ file: (cb: (f: File) => void) => cb(file1),
+ } as unknown as FileSystemFileEntry
+
+ const mockFileEntry2 = {
+ isFile: true,
+ isDirectory: false,
+ file: (cb: (f: File) => void) => cb(file2),
+ } as unknown as FileSystemFileEntry
+
+ let callCount = 0
+
+ const mockDirEntry = {
+ isFile: false,
+ isDirectory: true,
+ createReader: () => ({
+ readEntries: (cb: (batch: FileSystemEntry[]) => void) => {
+ if (callCount++ === 0) {
+ cb([mockFileEntry1, mockFileEntry2])
+ } else {
+ cb([]) // second call: signal EOF
+ }
+ },
+ }),
+ } as unknown as FileSystemDirectoryEntry
+
+ const result = await (component as any).traverseFileTree(mockDirEntry)
+ expect(result).toEqual([file1, file2])
+ })
+
+ it('should resolve a non-file non-directory entry as an empty array', () => {
+ const mockEntry = {
+ isFile: false,
+ isDirectory: false,
+ file: (cb: (f: File) => void) => cb(new File([], '')),
+ } as unknown as FileSystemEntry
+ return (component as any)
+ .traverseFileTree(mockEntry)
+ .then((result: File[]) => {
+ expect(result).toEqual([])
+ })
+ })
+
it('should ignore events if disabled', fakeAsync(() => {
settingsService.globalDropzoneEnabled = false
expect(settingsService.globalDropzoneActive).toBeFalsy()
diff --git a/src-ui/src/app/components/file-drop/file-drop.component.ts b/src-ui/src/app/components/file-drop/file-drop.component.ts
index 49eb423b2..62d738122 100644
--- a/src-ui/src/app/components/file-drop/file-drop.component.ts
+++ b/src-ui/src/app/components/file-drop/file-drop.component.ts
@@ -1,9 +1,4 @@
-import { Component, HostListener, ViewChild } from '@angular/core'
-import {
- NgxFileDropComponent,
- NgxFileDropEntry,
- NgxFileDropModule,
-} from 'ngx-file-drop'
+import { Component, HostListener } from '@angular/core'
import {
PermissionAction,
PermissionsService,
@@ -17,7 +12,7 @@ import { UploadDocumentsService } from 'src/app/services/upload-documents.servic
selector: 'pngx-file-drop',
templateUrl: './file-drop.component.html',
styleUrls: ['./file-drop.component.scss'],
- imports: [NgxFileDropModule],
+ imports: [],
})
export class FileDropComponent {
private fileLeaveTimeoutID: any
@@ -41,8 +36,6 @@ export class FileDropComponent {
)
}
- @ViewChild('ngxFileDrop') ngxFileDrop: NgxFileDropComponent
-
@HostListener('document:dragover', ['$event']) onDragOver(event: DragEvent) {
if (!this.dragDropEnabled || !event.dataTransfer?.types?.includes('Files'))
return
@@ -78,19 +71,85 @@ export class FileDropComponent {
}, ms)
}
+ private traverseFileTree(entry: FileSystemEntry): Promise
{
+ if (entry.isFile) {
+ return new Promise((resolve, reject) => {
+ ;(entry as FileSystemFileEntry).file(resolve, reject)
+ }).then((file: File) => [file])
+ }
+
+ if (entry.isDirectory) {
+ return new Promise((resolve, reject) => {
+ const dirReader = (entry as FileSystemDirectoryEntry).createReader()
+ const allEntries: FileSystemEntry[] = []
+
+ const readEntries = () => {
+ dirReader.readEntries((batch) => {
+ if (batch.length === 0) {
+ const promises = allEntries.map((child) =>
+ this.traverseFileTree(child)
+ )
+ Promise.all(promises)
+ .then((results) => resolve([].concat(...results)))
+ .catch(reject)
+ } else {
+ allEntries.push(...batch)
+ readEntries() // keep reading
+ }
+ }, reject)
+ }
+
+ readEntries()
+ })
+ }
+
+ return Promise.resolve([])
+ }
+
@HostListener('document:drop', ['$event']) public onDrop(event: DragEvent) {
if (!this.dragDropEnabled) return
event.preventDefault()
event.stopImmediatePropagation()
- // pass event onto ngx-file-drop to handle files
- this.ngxFileDrop.dropFiles(event)
- this.onDragLeave(event, true)
- }
- public dropped(files: NgxFileDropEntry[]) {
- this.uploadDocumentsService.onNgxFileDrop(files)
- if (files.length > 0)
+ const files: File[] = []
+ const entries: FileSystemEntry[] = []
+ if (event.dataTransfer?.items && event.dataTransfer.items.length) {
+ for (const item of Array.from(event.dataTransfer.items)) {
+ if (item.webkitGetAsEntry) {
+ // webkitGetAsEntry not standard, but is widely supported
+ const entry = item.webkitGetAsEntry()
+ if (entry) entries.push(entry)
+ } else if (item.kind === 'file') {
+ const file = item.getAsFile()
+ if (file) files.push(file)
+ }
+ }
+ } else if (event.dataTransfer?.files) {
+ // Fallback for browsers without DataTransferItem API
+ for (const file of Array.from(event.dataTransfer.files)) {
+ files.push(file)
+ }
+ }
+
+ if (entries.length) {
+ const promises = entries.map((entry) => this.traverseFileTree(entry))
+ Promise.all(promises)
+ .then((results) => {
+ files.push(...[].concat(...results))
+ this.toastService.showInfo($localize`Initiating upload...`, 3000)
+ files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
+ })
+ .catch((e) => {
+ this.toastService.showError(
+ $localize`Failed to read dropped items: ${e.message}`
+ )
+ })
+ } else if (files.length) {
this.toastService.showInfo($localize`Initiating upload...`, 3000)
+ files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
+ }
+
+ this.onDragLeave(event, true)
}
@HostListener('window:blur', ['$event']) public onWindowBlur() {
diff --git a/src-ui/src/app/services/upload-documents.service.spec.ts b/src-ui/src/app/services/upload-documents.service.spec.ts
index 28fb5b2e0..c4f68391a 100644
--- a/src-ui/src/app/services/upload-documents.service.spec.ts
+++ b/src-ui/src/app/services/upload-documents.service.spec.ts
@@ -15,33 +15,6 @@ import {
WebsocketStatusService,
} from './websocket-status.service'
-const files = [
- {
- lastModified: 1693349892540,
- lastModifiedDate: new Date(),
- name: 'file1.pdf',
- size: 386,
- type: 'application/pdf',
- },
- {
- lastModified: 1695618533892,
- lastModifiedDate: new Date(),
- name: 'file2.pdf',
- size: 358265,
- type: 'application/pdf',
- },
-]
-
-const fileList = {
- item: (x) => {
- return new File(
- [new Blob(['testing'], { type: files[x].type })],
- files[x].name
- )
- },
- length: files.length,
-} as unknown as FileList
-
describe('UploadDocumentsService', () => {
let httpTestingController: HttpTestingController
let uploadDocumentsService: UploadDocumentsService
@@ -68,7 +41,11 @@ describe('UploadDocumentsService', () => {
})
it('calls post_document api endpoint on upload', () => {
- uploadDocumentsService.uploadFiles(fileList)
+ const file = new File(
+ [new Blob(['testing'], { type: 'application/pdf' })],
+ 'file.pdf'
+ )
+ uploadDocumentsService.uploadFile(file)
const req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
@@ -78,7 +55,16 @@ describe('UploadDocumentsService', () => {
})
it('updates progress during upload and failure', () => {
- uploadDocumentsService.uploadFiles(fileList)
+ const file = new File(
+ [new Blob(['testing'], { type: 'application/pdf' })],
+ 'file.pdf'
+ )
+ const file2 = new File(
+ [new Blob(['testing'], { type: 'application/pdf' })],
+ 'file2.pdf'
+ )
+ uploadDocumentsService.uploadFile(file)
+ uploadDocumentsService.uploadFile(file2)
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
2
@@ -103,7 +89,11 @@ describe('UploadDocumentsService', () => {
})
it('updates progress on failure', () => {
- uploadDocumentsService.uploadFiles(fileList)
+ const file = new File(
+ [new Blob(['testing'], { type: 'application/pdf' })],
+ 'file.pdf'
+ )
+ uploadDocumentsService.uploadFile(file)
let req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
@@ -125,7 +115,7 @@ describe('UploadDocumentsService', () => {
websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(1)
- uploadDocumentsService.uploadFiles(fileList)
+ uploadDocumentsService.uploadFile(file)
req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
@@ -143,35 +133,4 @@ describe('UploadDocumentsService', () => {
websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(2)
})
-
- it('accepts files via drag and drop', () => {
- const uploadSpy = jest.spyOn(
- UploadDocumentsService.prototype as any,
- 'uploadFile'
- )
- const fileEntry = {
- name: 'file.pdf',
- isDirectory: false,
- isFile: true,
- file: (callback) => {
- return callback(
- new File(
- [new Blob(['testing'], { type: 'application/pdf' })],
- 'file.pdf'
- )
- )
- },
- }
- uploadDocumentsService.onNgxFileDrop([
- {
- relativePath: 'path/to/file.pdf',
- fileEntry,
- },
- ])
- expect(uploadSpy).toHaveBeenCalled()
-
- let req = httpTestingController.match(
- `${environment.apiBaseUrl}documents/post_document/`
- )
- })
})
diff --git a/src-ui/src/app/services/upload-documents.service.ts b/src-ui/src/app/services/upload-documents.service.ts
index e2d1b52f4..393d7c682 100644
--- a/src-ui/src/app/services/upload-documents.service.ts
+++ b/src-ui/src/app/services/upload-documents.service.ts
@@ -1,6 +1,5 @@
import { HttpEventType } from '@angular/common/http'
import { Injectable } from '@angular/core'
-import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop'
import { Subscription } from 'rxjs'
import { DocumentService } from './rest/document.service'
import {
@@ -19,22 +18,7 @@ export class UploadDocumentsService {
private websocketStatusService: WebsocketStatusService
) {}
- onNgxFileDrop(files: NgxFileDropEntry[]) {
- for (const droppedFile of files) {
- if (droppedFile.fileEntry.isFile) {
- const fileEntry = droppedFile.fileEntry as FileSystemFileEntry
- fileEntry.file((file: File) => this.uploadFile(file))
- }
- }
- }
-
- uploadFiles(files: FileList) {
- for (let index = 0; index < files.length; index++) {
- this.uploadFile(files.item(index))
- }
- }
-
- private uploadFile(file: File) {
+ public uploadFile(file: File) {
let formData = new FormData()
formData.append('document', file, file.name)
formData.append('from_webui', 'true')
diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts
index eb34b94b4..ae6f80915 100644
--- a/src-ui/src/main.ts
+++ b/src-ui/src/main.ts
@@ -135,7 +135,6 @@ import {
} from 'ngx-bootstrap-icons'
import { ColorSliderModule } from 'ngx-color/slider'
import { CookieService } from 'ngx-cookie-service'
-import { NgxFileDropModule } from 'ngx-file-drop'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { AppRoutingModule } from './app/app-routing.module'
import { AppComponent } from './app/app.component'
@@ -353,7 +352,6 @@ bootstrapApplication(AppComponent, {
FormsModule,
ReactiveFormsModule,
PdfViewerModule,
- NgxFileDropModule,
NgSelectModule,
ColorSliderModule,
TourNgBootstrapModule,