diff --git a/changelog/unreleased/enhancement-move-file-drag-drop b/changelog/unreleased/enhancement-move-file-drag-drop new file mode 100644 index 00000000000..be14d9fe3c8 --- /dev/null +++ b/changelog/unreleased/enhancement-move-file-drag-drop @@ -0,0 +1,6 @@ +Enhancement: Move file via drag and drop + +We've added moving files and folders via drag and drop to the files table view. + +https://github.com/owncloud/web/issues/5592 +https://github.com/owncloud/web/pull/5588 \ No newline at end of file diff --git a/packages/web-app-files/src/App.vue b/packages/web-app-files/src/App.vue index 0d3af18ac05..a923b2415c6 100644 --- a/packages/web-app-files/src/App.vue +++ b/packages/web-app-files/src/App.vue @@ -104,9 +104,9 @@ export default { revert: event === 'beforeDestroy' }) }, - - $_ocApp_dragOver() { - this.dragOver(true) + $_ocApp_dragOver(event) { + const hasfileInEvent = (event.dataTransfer.types || []).some(e => e === 'Files') + this.dragOver(hasfileInEvent) } } } diff --git a/packages/web-app-files/src/views/Personal.vue b/packages/web-app-files/src/views/Personal.vue index e829852da8b..93876debc94 100644 --- a/packages/web-app-files/src/views/Personal.vue +++ b/packages/web-app-files/src/views/Personal.vue @@ -26,6 +26,8 @@ :resources="activeFiles" :target-route="targetRoute" :header-position="headerPosition" + :drag-drop="true" + @fileDropped="fileDropped" @showDetails="$_mountSideBar_showDefaultPanel" @fileClick="$_fileActions_triggerDefaultAction" @rowMounted="rowMounted" @@ -81,6 +83,8 @@ import ListInfo from '../components/FilesList/ListInfo.vue' import Pagination from '../components/FilesList/Pagination.vue' import ContextActions from '../components/FilesList/ContextActions.vue' import { DavProperties } from 'web-pkg/src/constants' +import { basename, join } from 'path' +import PQueue from 'p-queue' const visibilityObserver = new VisibilityObserver() @@ -160,13 +164,16 @@ export default { $route: { handler: function(to, from) { if (isNil(this.$route.params.item)) { - this.$router.push({ - name: 'files-personal', - params: { - item: this.homeFolder - } - }) - + this.$router + .push({ + name: 'files-personal', + params: { + item: this.homeFolder + } + }) + .catch(error => { + console.log(error) + }) return } @@ -204,15 +211,99 @@ export default { methods: { ...mapActions('Files', ['loadIndicators', 'loadPreview']), + ...mapActions(['showMessage']), ...mapMutations('Files', [ 'SELECT_RESOURCES', 'SET_CURRENT_FOLDER', 'LOAD_FILES', 'CLEAR_CURRENT_FILES_LIST', - 'UPDATE_CURRENT_PAGE' + 'REMOVE_FILE', + 'REMOVE_FILE_FROM_SEARCHED', + 'REMOVE_FILE_SELECTION' ]), ...mapMutations(['SET_QUOTA']), + async fileDropped(fileIdTarget) { + const selected = [...this.selectedFiles] + const targetInfo = this.activeFiles.find(e => e.id === fileIdTarget) + const isTargetSelected = selected.some(e => e.id === fileIdTarget) + if (isTargetSelected) return + if (targetInfo.type !== 'folder') return + const itemsInTarget = await this.fetchResources(targetInfo.path) + + // try to move all selected files + const errors = [] + const movePromises = [] + const moveQueue = new PQueue({ concurrency: 4 }) + selected.forEach(resource => { + movePromises.push( + moveQueue.add(async () => { + const exists = itemsInTarget.some(e => basename(e.name) === resource.name) + if (exists) { + const message = this.$gettext('Resource with name %{name} already exists') + errors.push({ + resource: resource.name, + message: this.$gettextInterpolate(message, { name: resource.name }, true) + }) + return + } + + try { + await this.$client.files.move(resource.path, join(targetInfo.path, resource.name)) + this.REMOVE_FILE(resource) + this.REMOVE_FILE_FROM_SEARCHED(resource) + this.REMOVE_FILE_SELECTION(resource) + } catch (error) { + error.resourceName = resource.name + errors.push(error) + } + }) + ) + }) + await Promise.all(movePromises) + + // show error / success messages + let title + let desc + if (errors.length === 0) { + const count = selected.length + title = this.$ngettext('%{count} item moved', '%{count} items moved', count) + desc = this.$ngettext( + 'Successfully moved %{count} item', + 'Successfully moved %{count} items', + count + ) + this.showMessage({ + title: this.$gettextInterpolate(title, { count }), + desc: this.$gettextInterpolate(desc, { count }), + status: 'success' + }) + return + } + + if (errors.length === 1) { + title = this.$gettext('An error occurred while moving %{resource}') + this.showMessage({ + title: this.$gettextInterpolate(title, { resource: errors[0].resourceName }, true), + desc: errors[0].message, + status: 'danger' + }) + return + } + + title = this.$gettext('An error occurred while moving several resources') + desc = this.$ngettext( + '%{count} resource could not be moved', + '%{count} resources could not be moved', + errors.length + ) + this.showMessage({ + title, + desc: this.$gettextInterpolate(desc, { count: errors.length }, false), + status: 'danger' + }) + }, + rowMounted(resource, component) { if (!this.displayThumbnails) { return @@ -230,17 +321,19 @@ export default { visibilityObserver.observe(component.$el, { onEnter: debounced, onExit: debounced.cancel }) }, - - async loadResources(sameRoute, path = null) { + async fetchResources(path, properties) { + try { + return await this.$client.files.list(path, 1, properties) + } catch (error) { + console.error(error) + } + }, + async loadResources(sameRoute) { this.loading = true this.CLEAR_CURRENT_FILES_LIST() try { - let resources = await this.$client.files.list( - path || this.$route.params.item, - 1, - DavProperties.Default - ) + let resources = await this.fetchResources(this.$route.params.item, DavProperties.Default) resources = resources.map(buildResource) this.LOAD_FILES({ diff --git a/packages/web-app-files/tests/unit/views/Personal.spec.js b/packages/web-app-files/tests/unit/views/Personal.spec.js new file mode 100644 index 00000000000..2a82aafbe82 --- /dev/null +++ b/packages/web-app-files/tests/unit/views/Personal.spec.js @@ -0,0 +1,221 @@ +import Vuex from 'vuex' +import VueRouter from 'vue-router' +import GetTextPlugin from 'vue-gettext' +import { mount } from '@vue/test-utils' +import { localVue } from './views.setup' +import { createStore } from 'vuex-extensions' +import Personal from 'packages/web-app-files/src/views/Personal.vue' +import MixinAccessibleBreadcrumb from '../../../src/mixins/accessibleBreadcrumb' +import MixinFileActions from '../../../src/mixins/fileActions' +import MixinFilesListFilter from '../../../src/mixins/filesListFilter' +import MixinFilesListScrolling from '../../../src/mixins/filesListScrolling' +import MixinFilesListPositioning from '../../../src/mixins/filesListPositioning' +import MixinFilesListPagination from '../../../src/mixins/filesListPagination' +import MixinMountSideBar from '../../../src/mixins/sidebar/mountSideBar' + +localVue.use(GetTextPlugin, { + translations: 'does-not-matter.json', + silent: true +}) +localVue.use(VueRouter) + +const configuration = { + options: { + disablePreviews: true + } +} +const user = { + id: 1, + quota: 1 +} + +localVue.prototype.$client = { + files: { + move: jest.fn(), + list: jest.fn(() => []) + }, + users: { + getUser: jest.fn(() => user) + } +} + +const router = new VueRouter({ + routes: [ + { + path: '/', + name: 'files-personal' + } + ] +}) + +jest.unmock('axios') + +const stubs = { + translate: true, + 'oc-pagination': true, + 'list-loader': true, + 'oc-table-files': true, + 'not-found-message': true, + 'quick-actions': true, + 'list-info': true +} + +const resourceForestJpg = { + id: 'forest', + name: 'forest.jpg', + path: 'images/nature/forest.jpg', + thumbnail: 'https://cdn.pixabay.com/photo/2015/09/09/16/05/forest-931706_960_720.jpg', + type: 'file', + size: '111000234', + mdate: 'Thu, 01 Jul 2021 08:34:04 GMT' +} +const resourceNotesTxt = { + id: 'notes', + name: 'notes.txt', + path: '/Documents/notes.txt', + icon: 'text', + type: 'file', + size: '1245', + mdate: 'Thu, 01 Jul 2021 08:45:04 GMT' +} +const resourceDocumentsFolder = { + id: 'documents', + name: 'Documents', + path: '/Documents', + icon: 'folder', + type: 'folder', + size: '5324435', + mdate: 'Sat, 09 Jan 2021 14:34:04 GMT' +} +const resourcePdfsFolder = { + id: 'pdfs', + name: 'Pdfs', + path: '/pdfs', + icon: 'folder', + type: 'folder', + size: '53244', + mdate: 'Sat, 09 Jan 2021 14:34:04 GMT' +} + +const resourcesFiles = [resourceForestJpg, resourceNotesTxt] +const resourcesFolders = [resourceDocumentsFolder, resourcePdfsFolder] +const resources = [...resourcesFiles, ...resourcesFolders] + +function createWrapper(selectedFiles = [resourceForestJpg]) { + jest.spyOn(Personal.methods, 'loadResources').mockImplementation() + jest + .spyOn(MixinAccessibleBreadcrumb.methods, 'accessibleBreadcrumb_focusAndAnnounceBreadcrumb') + .mockImplementation() + const component = { ...Personal, created: jest.fn(), mounted: jest.fn() } + const wrapper = mount(component, { + store: createStore(Vuex.Store, { + state: { + app: { quickActions: {} } + }, + getters: { + configuration: () => configuration, + homeFolder: () => '/', + user: () => user + }, + mutations: { + SET_QUOTA: () => {} + }, + actions: { + showMessage: () => {} + }, + modules: { + Files: { + state: { + resource: null, + currentPage: 1 + }, + getters: { + activeFiles: () => [...resources], + inProgress: () => [null], + currentFolder: () => '/', + pages: () => 4, + selectedFiles: () => [...selectedFiles], + highlightedFile: () => resourceForestJpg + }, + actions: { + loadIndicators: () => {} + }, + mutations: { + SET_FILES_PAGE_LIMIT: () => {}, + CLEAR_FILES_SEARCHED: () => {}, + SET_CURRENT_FOLDER: () => {}, + LOAD_FILES: () => {}, + CLEAR_CURRENT_FILES_LIST: () => {}, + REMOVE_FILE: () => {}, + REMOVE_FILE_FROM_SEARCHED: () => {}, + REMOVE_FILE_SELECTION: () => {} + }, + namespaced: true + } + } + }), + localVue, + router, + stubs: stubs, + mixins: [ + MixinAccessibleBreadcrumb, + MixinFileActions, + MixinFilesListPositioning, + MixinFilesListScrolling, + MixinFilesListPagination, + MixinMountSideBar, + MixinFilesListFilter + ], + data: () => ({ + loading: false + }) + }) + return wrapper +} + +describe('Personal view', () => { + describe('file move with drag & drop', () => { + it('should exit if target is also selected', async () => { + const spyOnGetFolderItems = jest.spyOn(Personal.methods, 'fetchResources') + const wrapper = createWrapper([resourceForestJpg, resourcePdfsFolder]) + await wrapper.vm.fileDropped(resourcePdfsFolder.id) + expect(spyOnGetFolderItems).not.toBeCalled() + spyOnGetFolderItems.mockReset() + }) + it('should exit if target is not a folder', async () => { + const spyOnGetFolderItems = jest.spyOn(Personal.methods, 'fetchResources') + const wrapper = createWrapper([resourceDocumentsFolder]) + await wrapper.vm.fileDropped(resourceForestJpg.id) + expect(spyOnGetFolderItems).not.toBeCalled() + spyOnGetFolderItems.mockReset() + }) + it('should not move file if resource is already in target', async () => { + const spyOnGetFolderItems = jest + .spyOn(Personal.methods, 'fetchResources') + .mockResolvedValueOnce([resourceDocumentsFolder]) + const spyOnMoveFiles = jest.spyOn(localVue.prototype.$client.files, 'move') + + const wrapper = createWrapper([resourceDocumentsFolder]) + await wrapper.vm.fileDropped(resourcePdfsFolder.id) + expect(spyOnMoveFiles).not.toBeCalled() + + spyOnMoveFiles.mockReset() + spyOnGetFolderItems.mockReset() + }) + it('should move a file', async () => { + const spyOnGetFolderItems = jest + .spyOn(Personal.methods, 'fetchResources') + .mockResolvedValueOnce([]) + const spyOnMoveFilesMove = jest + .spyOn(localVue.prototype.$client.files, 'move') + .mockImplementation() + + const wrapper = createWrapper([resourceDocumentsFolder]) + await wrapper.vm.fileDropped(resourcePdfsFolder.id) + expect(spyOnMoveFilesMove).toBeCalled() + + spyOnMoveFilesMove.mockReset() + spyOnGetFolderItems.mockReset() + }) + }) +}) diff --git a/packages/web-app-files/tests/unit/views/views.setup.js b/packages/web-app-files/tests/unit/views/views.setup.js index 8d6b8c0314b..1e573cd1d53 100644 --- a/packages/web-app-files/tests/unit/views/views.setup.js +++ b/packages/web-app-files/tests/unit/views/views.setup.js @@ -40,7 +40,8 @@ export const getStore = function({ getters: { configuration: () => configuration, getToken: () => '', - isOcis: () => true + isOcis: () => true, + homeFolder: () => '/' }, modules: { Files: { @@ -57,14 +58,16 @@ export const getStore = function({ activeFilesCount: () => ({ files: 0, folders: 1 }), inProgress: () => [null], highlightedFile: () => highlightedFile, - pages: () => pages + pages: () => pages, + currentFolder: () => '/' }, mutations: { UPDATE_RESOURCE: (state, resource) => { state.resource = resource }, UPDATE_CURRENT_PAGE: () => {}, - SET_FILES_PAGE_LIMIT: () => {} + SET_FILES_PAGE_LIMIT: () => {}, + CLEAR_FILES_SEARCHED: () => {} }, namespaced: true }