From b6b7a8f9e834dbdbd395e09677685317fe7f5692 Mon Sep 17 00:00:00 2001 From: Yazeed Date: Sat, 18 Jul 2026 22:11:58 +0300 Subject: [PATCH 1/2] feat: add drag-and-drop document upload to Files page Adds drag-and-drop as an additional input method for the existing upload flow on the LibreSign Files page. Dropped files are routed through the same validation, envelope, and upload logic as the existing "Upload" button by extracting a shared RequestPicker.handleFilesSelected() entry point, so there are no separate rules for drag-and-drop vs. click-to-upload. - FilesList.vue gains dragenter/dragover/dragleave/drop handlers on the page content area, with a visual drop-target overlay, gated on the existing canRequestSign permission. - RequestPicker.vue's max-file-uploads and envelope-name-dialog logic is reused as-is; a new guard shows an error when multiple files are dropped while envelopes are disabled (previously unreachable via the single-select file picker, but reachable via drag-and-drop). Closes #7926 Co-Authored-By: Claude Sonnet 5 Signed-off-by: Yazeed --- src/components/Request/RequestPicker.vue | 44 +++-- .../components/Request/RequestPicker.spec.ts | 71 ++++++++ src/tests/views/FilesList/FilesList.spec.ts | 119 ++++++++++++ src/views/FilesList/FilesList.vue | 171 +++++++++++++----- 4 files changed, 344 insertions(+), 61 deletions(-) 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..2b2c7003df 100644 --- a/src/views/FilesList/FilesList.vue +++ b/src/views/FilesList/FilesList.vue @@ -4,55 +4,95 @@ --> @@ -198,6 +169,57 @@ function toggleGridView() { userConfigStore.update('files_list_grid_view', !isGridView.value) } +function isFileDrag(event: DragEvent) { + return Array.from(event.dataTransfer?.types ?? []).includes('Files') +} + +function onDragEnter(event: DragEvent) { + if (!canRequestSign.value || !isFileDrag(event)) { + return + } + event.preventDefault() + dragDepth.value++ + isDraggingFiles.value = true +} + +function onDragOver(event: DragEvent) { + if (!canRequestSign.value || !isFileDrag(event)) { + return + } + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy' + } +} + +function onDragLeave(event: DragEvent) { + if (!canRequestSign.value || !isFileDrag(event)) { + return + } + event.preventDefault() + dragDepth.value = Math.max(0, dragDepth.value - 1) + if (dragDepth.value === 0) { + isDraggingFiles.value = false + } +} + +async function onDrop(event: DragEvent) { + event.preventDefault() + dragDepth.value = 0 + isDraggingFiles.value = false + + if (!canRequestSign.value) { + return + } + + const files = Array.from(event.dataTransfer?.files ?? []) + if (files.length === 0) { + return + } + + await requestPickerRef.value?.handleFilesSelected?.(files) +} + function checkAndOpenFileFromUri() { const query = route.query as { uuid?: string | string[] } const uuid = Array.isArray(query.uuid) ? query.uuid[0] : query.uuid