diff --git a/src/components/Request/RequestPicker.vue b/src/components/Request/RequestPicker.vue index 39fd7dc5ac..90cadbaa4d 100644 --- a/src/components/Request/RequestPicker.vue +++ b/src/components/Request/RequestPicker.vue @@ -380,6 +380,31 @@ function cancelUpload() { uploadAbortController.value?.abort() } +async function handleFilesSelected(files: UploadFile[]) { + if (files.length === 0) { + return + } + + if (!validateMaxFileUploads(files.length)) { + return + } + + if (files.length > 1 && !envelopeEnabled.value) { + // TRANSLATORS Shown when more than one file is selected/dropped but envelopes (multi-file requests) are not enabled. + showError(t('libresign', 'Only one file can be uploaded at a time.')) + return + } + + if (files.length > 1 && envelopeEnabled.value) { + pendingFiles.value = files + envelopeNameInput.value = '' + showEnvelopeNameDialog.value = true + return + } + + await upload(files) +} + function uploadFile() { openedMenu.value = false const input = document.createElement('input') @@ -390,23 +415,7 @@ function uploadFile() { input.onchange = async (event) => { const target = event.target as HTMLInputElement | null const files = Array.from(target?.files ?? []) as unknown as UploadFile[] - if (!validateMaxFileUploads(files.length)) { - input.remove() - return - } - - if (files.length > 1 && envelopeEnabled.value) { - pendingFiles.value = files - envelopeNameInput.value = '' - showEnvelopeNameDialog.value = true - input.remove() - return - } - - if (files.length > 0) { - await upload(files) - } - + await handleFilesSelected(files) input.remove() } @@ -523,6 +532,7 @@ defineExpose({ closeModalUploadFromUrl, upload, cancelUpload, + handleFilesSelected, uploadFile, uploadUrl, handleFileChoose, diff --git a/src/tests/components/Request/RequestPicker.spec.ts b/src/tests/components/Request/RequestPicker.spec.ts index cce2c4d8c3..6e4e2067f2 100644 --- a/src/tests/components/Request/RequestPicker.spec.ts +++ b/src/tests/components/Request/RequestPicker.spec.ts @@ -458,6 +458,77 @@ describe('RequestPicker component rules', () => { }) }) + describe('handleFilesSelected (shared entry point for click-upload and drag-and-drop)', () => { + it('does nothing when no files are provided', async () => { + await wrapper.vm.handleFilesSelected([]) + expect(filesStore.upload).not.toHaveBeenCalled() + }) + + it('shows error and does not upload when exceeding max file uploads limit', async () => { + getCapabilitiesMock.mockReturnValue({ + libresign: { + config: { + envelope: { 'is-available': true }, + upload: { 'max-file-uploads': 1 }, + }, + }, + }) + const files = [ + { name: 'document1.pdf', size: 1000 }, + { name: 'document2.pdf', size: 1000 }, + ] + await wrapper.vm.handleFilesSelected(files) + expect(showError).toHaveBeenCalled() + expect(filesStore.upload).not.toHaveBeenCalled() + }) + + it('shows error and does not upload when multiple files provided while envelope is disabled', async () => { + getCapabilitiesMock.mockReturnValue({ + libresign: { + config: { + envelope: { 'is-available': false }, + upload: { 'max-file-uploads': 20 }, + }, + }, + }) + const files = [ + { name: 'document1.pdf', size: 1000 }, + { name: 'document2.pdf', size: 1000 }, + ] + await wrapper.vm.handleFilesSelected(files) + expect(showError).toHaveBeenCalledWith('Only one file can be uploaded at a time.') + expect(filesStore.upload).not.toHaveBeenCalled() + expect(wrapper.vm.showEnvelopeNameDialog).toBe(false) + }) + + it('opens envelope name dialog for multiple files when envelope is enabled', async () => { + getCapabilitiesMock.mockReturnValue({ + libresign: { + config: { + envelope: { 'is-available': true }, + upload: { 'max-file-uploads': 20 }, + }, + }, + }) + const files = [ + { name: 'document1.pdf', size: 1000 }, + { name: 'document2.pdf', size: 1000 }, + ] + await wrapper.vm.handleFilesSelected(files) + expect(wrapper.vm.showEnvelopeNameDialog).toBe(true) + expect(wrapper.vm.pendingFiles).toEqual(files) + expect(filesStore.upload).not.toHaveBeenCalled() + }) + + it('uploads directly when a single file is provided', async () => { + filesStore.upload.mockResolvedValue(42) + const files = [{ name: 'document.pdf', size: 1000 }] + await wrapper.vm.handleFilesSelected(files) + expect(filesStore.upload).toHaveBeenCalled() + expect(filesStore.selectFile).toHaveBeenCalledWith(42) + }) + }) + describe('envelope name dialog submission', () => { it('creates envelope with trimmed name when minimum length met', async () => { filesStore.upload.mockResolvedValue(1) diff --git a/src/tests/views/FilesList/FilesList.spec.ts b/src/tests/views/FilesList/FilesList.spec.ts index dcb4386191..13fa425d2a 100644 --- a/src/tests/views/FilesList/FilesList.spec.ts +++ b/src/tests/views/FilesList/FilesList.spec.ts @@ -122,10 +122,17 @@ vi.mock('../../../views/FilesList/FileListFilters.vue', () => ({ }, })) +const handleFilesSelectedMock = vi.fn() + vi.mock('../../../components/Request/RequestPicker.vue', () => ({ default: { name: 'RequestPicker', template: '
', + methods: { + handleFilesSelected(files: File[]) { + return handleFilesSelectedMock(files) + }, + }, }, })) @@ -313,4 +320,116 @@ describe('FilesList.vue rendering rules', () => { expect(selectSpy).toHaveBeenCalledWith() }) + + describe('drag-and-drop upload', () => { + function createDragEvent(files: File[] = [], types: string[] = ['Files']) { + return { + preventDefault: vi.fn(), + dataTransfer: { + types, + files, + dropEffect: '', + }, + } as unknown as DragEvent + } + + it('shows the drop overlay when a file is dragged over and user can request sign', async () => { + const filesStore = useFilesStore() + filesStore.canRequestSign = true + vi.spyOn(filesStore, 'getAllFiles').mockResolvedValue({}) + + const wrapper = mountComponent() + await flushPromises() + + expect(wrapper.vm.isDraggingFiles).toBe(false) + wrapper.vm.onDragEnter(createDragEvent()) + expect(wrapper.vm.isDraggingFiles).toBe(true) + }) + + it('does not show the drop overlay when user cannot request sign', async () => { + const filesStore = useFilesStore() + filesStore.canRequestSign = false + vi.spyOn(filesStore, 'getAllFiles').mockResolvedValue({}) + + const wrapper = mountComponent() + await flushPromises() + + wrapper.vm.onDragEnter(createDragEvent()) + expect(wrapper.vm.isDraggingFiles).toBe(false) + }) + + it('ignores drags that do not carry files', async () => { + const filesStore = useFilesStore() + filesStore.canRequestSign = true + vi.spyOn(filesStore, 'getAllFiles').mockResolvedValue({}) + + const wrapper = mountComponent() + await flushPromises() + + wrapper.vm.onDragEnter(createDragEvent([], ['text/plain'])) + expect(wrapper.vm.isDraggingFiles).toBe(false) + }) + + it('hides the overlay only after the last dragleave of nested elements', async () => { + const filesStore = useFilesStore() + filesStore.canRequestSign = true + vi.spyOn(filesStore, 'getAllFiles').mockResolvedValue({}) + + const wrapper = mountComponent() + await flushPromises() + + wrapper.vm.onDragEnter(createDragEvent()) + wrapper.vm.onDragEnter(createDragEvent()) + expect(wrapper.vm.isDraggingFiles).toBe(true) + + wrapper.vm.onDragLeave(createDragEvent()) + expect(wrapper.vm.isDraggingFiles).toBe(true) + + wrapper.vm.onDragLeave(createDragEvent()) + expect(wrapper.vm.isDraggingFiles).toBe(false) + }) + + it('delegates dropped files to RequestPicker.handleFilesSelected and hides the overlay', async () => { + const filesStore = useFilesStore() + filesStore.canRequestSign = true + vi.spyOn(filesStore, 'getAllFiles').mockResolvedValue({}) + + const wrapper = mountComponent() + await flushPromises() + + const file = new File(['content'], 'document.pdf', { type: 'application/pdf' }) + wrapper.vm.onDragEnter(createDragEvent([file])) + await wrapper.vm.onDrop(createDragEvent([file])) + + expect(handleFilesSelectedMock).toHaveBeenCalledWith([file]) + expect(wrapper.vm.isDraggingFiles).toBe(false) + }) + + it('does not upload dropped files when user cannot request sign', async () => { + const filesStore = useFilesStore() + filesStore.canRequestSign = false + vi.spyOn(filesStore, 'getAllFiles').mockResolvedValue({}) + + const wrapper = mountComponent() + await flushPromises() + + const file = new File(['content'], 'document.pdf', { type: 'application/pdf' }) + await wrapper.vm.onDrop(createDragEvent([file])) + + expect(handleFilesSelectedMock).not.toHaveBeenCalled() + }) + + it('does not call handleFilesSelected when no files are dropped', async () => { + const filesStore = useFilesStore() + filesStore.canRequestSign = true + vi.spyOn(filesStore, 'getAllFiles').mockResolvedValue({}) + + const wrapper = mountComponent() + await flushPromises() + + await wrapper.vm.onDrop(createDragEvent([])) + + expect(handleFilesSelectedMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/views/FilesList/FilesList.vue b/src/views/FilesList/FilesList.vue index 0c2a607681..2293d3fe39 100644 --- a/src/views/FilesList/FilesList.vue +++ b/src/views/FilesList/FilesList.vue @@ -4,86 +4,97 @@ -->