From 0341cfd66a99d15c8627de74e7aa80b686aba819 Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Mon, 30 Aug 2021 17:27:51 +0200
Subject: [PATCH] UI: added methods for copying nodes and handles copy/move of
 multiple nodes at the same time

---
 vospace-ui-frontend/src/api/server/index.js   |   9 +-
 vospace-ui-frontend/src/components/Main.vue   |  50 +++++++-
 .../src/components/modal/MoveModal.vue        |  79 -------------
 .../src/components/modal/MoveOrCopyModal.vue  | 111 ++++++++++++++++++
 .../src/components/modal/RenameModal.vue      |   7 +-
 vospace-ui-frontend/src/main.js               |  18 ++-
 vospace-ui-frontend/src/store.js              |  65 ++++++----
 7 files changed, 226 insertions(+), 113 deletions(-)
 delete mode 100644 vospace-ui-frontend/src/components/modal/MoveModal.vue
 create mode 100644 vospace-ui-frontend/src/components/modal/MoveOrCopyModal.vue

diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js
index dde1066..4d09ab4 100644
--- a/vospace-ui-frontend/src/api/server/index.js
+++ b/vospace-ui-frontend/src/api/server/index.js
@@ -199,8 +199,9 @@ export default {
       data
     }, true, true);
   },
-  getNodesForMove(data) {
-    let url = BASE_API_URL + 'nodesForMove?path=' + escapePath(data.path) + '&nodeToMove=' + data.nodeToMove;
+  getNodesForMoveOrCopy(data) {
+    let url = BASE_API_URL + 'nodesForMoveOrCopy?path=' + escapePath(data.path) +
+      '&' + data.targets.map(t => 'target=' + escapePath(t)).join('&');
     return apiRequest({
       method: 'GET',
       url: url,
@@ -210,8 +211,8 @@ export default {
       }
     }, true, true);
   },
-  moveNode(data) {
-    let url = BASE_API_URL + 'move';
+  moveOrCopyNodes(data) {
+    let url = BASE_API_URL + 'moveOrCopy';
     return apiRequest({
       method: 'POST',
       url: url,
diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue
index cb505b8..9649232 100644
--- a/vospace-ui-frontend/src/components/Main.vue
+++ b/vospace-ui-frontend/src/components/Main.vue
@@ -12,6 +12,8 @@
     <b-dropdown variant="primary" text="Actions" v-if="actionsEnabled">
       <b-dropdown-item :disabled="!asyncButtonEnabled" @click="startAsyncRecallJob">Async recall</b-dropdown-item>
       <b-dropdown-item :disabled="!deleteButtonEnabled" @click="deleteNodes">Delete</b-dropdown-item>
+      <b-dropdown-item :disabled="!moveButtonEnabled" @click="moveNodes">Move</b-dropdown-item>
+      <b-dropdown-item :disabled="!copyButtonEnabled" @click="copyNodes">Copy</b-dropdown-item>
       <b-dropdown-item :disabled="!archiveButtonEnabled" @click="createArchive('zip')">Create zip archive</b-dropdown-item>
       <b-dropdown-item :disabled="!archiveButtonEnabled" @click="createArchive('tar')">Create tar archive</b-dropdown-item>
     </b-dropdown>
@@ -44,7 +46,7 @@
   <ConfirmDeleteModal />
   <ShareModal />
   <RenameModal />
-  <MoveModal />
+  <MoveOrCopyModal />
   <ConfirmArchiveModal />
 </div>
 </template>
@@ -56,7 +58,7 @@ import UploadFilesModal from './modal/UploadFilesModal.vue'
 import ConfirmDeleteModal from './modal/ConfirmDeleteModal.vue'
 import ShareModal from './modal/ShareModal.vue'
 import RenameModal from './modal/RenameModal.vue'
-import MoveModal from './modal/MoveModal.vue'
+import MoveOrCopyModal from './modal/MoveOrCopyModal.vue'
 import ConfirmArchiveModal from './modal/ConfirmArchiveModal.vue'
 
 export default {
@@ -68,7 +70,7 @@ export default {
     ConfirmDeleteModal,
     ShareModal,
     RenameModal,
-    MoveModal,
+    MoveOrCopyModal,
     ConfirmArchiveModal
   },
   computed: {
@@ -98,9 +100,15 @@ export default {
     deleteButtonEnabled() {
       return this.$store.state.deleteButtonEnabled;
     },
+    moveButtonEnabled() {
+      return this.$store.state.moveButtonEnabled;
+    },
     archiveButtonEnabled() {
       return this.$store.state.archiveButtonEnabled;
     },
+    copyButtonEnabled() {
+      return this.$store.state.copyButtonEnabled;
+    },
     writable() {
       return this.$store.state.writable;
     }
@@ -147,6 +155,42 @@ export default {
       this.$store.commit('setSelectedUndeletableNodes', unDeletablePaths);
       this.$bvModal.show('confirm-delete-modal');
     },
+    moveNodes() {
+      let selectedNodesCheckboxes = document.querySelectorAll('#nodes input:checked');
+      let paths = [];
+      let unDeletablePaths = [];
+      for (let i = 0; i < selectedNodesCheckboxes.length; i++) {
+        let checkbox = selectedNodesCheckboxes[i];
+        let dataNode = checkbox.getAttribute('data-node');
+        if (checkbox.classList.contains('deletable')) {
+          paths.push(dataNode);
+        } else {
+          unDeletablePaths.push(dataNode);
+        }
+      }
+      this.$store.commit('setNodesToMoveOrCopy', paths);
+      this.$store.commit('setMoveOrCopy', 'move');
+      this.$store.commit('setSelectedNotMovableNodes', unDeletablePaths);
+      this.$bvModal.show('move-or-copy-modal');
+    },
+    copyNodes() {
+      let selectedNodesCheckboxes = document.querySelectorAll('#nodes input:checked');
+      let paths = [];
+      let unCopiablePaths = [];
+      for (let i = 0; i < selectedNodesCheckboxes.length; i++) {
+        let checkbox = selectedNodesCheckboxes[i];
+        let dataNode = checkbox.getAttribute('data-node');
+        if (checkbox.classList.contains('async')) {
+          unCopiablePaths.push(dataNode);
+        } else {
+          paths.push(dataNode);
+        }
+      }
+      this.$store.commit('setNodesToMoveOrCopy', paths);
+      this.$store.commit('setMoveOrCopy', 'copy');
+      this.$store.commit('setSelectedNotCopiableNodes', unCopiablePaths);
+      this.$bvModal.show('move-or-copy-modal');
+    },
     createArchive(type) {
       let selectedNodesCheckboxes = document.querySelectorAll('#nodes input:checked');
       let nodesToArchive = [];
diff --git a/vospace-ui-frontend/src/components/modal/MoveModal.vue b/vospace-ui-frontend/src/components/modal/MoveModal.vue
deleted file mode 100644
index 53a3a88..0000000
--- a/vospace-ui-frontend/src/components/modal/MoveModal.vue
+++ /dev/null
@@ -1,79 +0,0 @@
-<!--
-  This file is part of vospace-ui
-  Copyright (C) 2021 Istituto Nazionale di Astrofisica
-  SPDX-License-Identifier: GPL-3.0-or-later
--->
-<template>
-<b-modal id="move-modal" :title="'Move ' + nodeToMove + ' to ' + destinationPath" okTitle="Move node" @show="afterShow" @ok.prevent="moveNode" :ok-disabled="!writable" size="lg">
-  <ol class="breadcrumb">
-    <li class="breadcrumb-item" v-for="(item, i) in breadcrumbs" :key="i" :class="{ 'active' : item.active }">
-      <a href="#" @click.stop.prevent="breadcrumbClick(i)" v-if="!item.active">{{item.text}}</a>
-      <span v-if="item.active">{{item.text}}</span>
-    </li>
-  </ol>
-  <div id="move-nodes-wrapper">
-    <div id="move-nodes"></div>
-  </div>
-</b-modal>
-</template>
-
-<script>
-export default {
-  name: 'MoveModal',
-  computed: {
-    nodeToMove() { return this.$store.state.nodeToMove },
-    destinationPath() { return this.$store.state.nodeToMoveDestination },
-    writable() { return this.$store.state.nodeToMoveDestinationWritable },
-    breadcrumbs() {
-      let items = [];
-      if (this.destinationPath !== null) {
-        let pathSplit = this.destinationPath.split('/');
-        for (let i = 0; i < pathSplit.length; i++) {
-          items.push({
-            text: i === 0 ? 'ROOT' : pathSplit[i],
-            active: i === pathSplit.length - 1
-          });
-        }
-      }
-      return items;
-    }
-  },
-  methods: {
-    afterShow() {
-      // starts from parent path
-      this.$store.dispatch('openNodeInMoveModal', this.nodeToMove.substring(0, this.nodeToMove.lastIndexOf('/')));
-    },
-    breadcrumbClick(i) {
-      let pathSplit = this.destinationPath.split('/');
-      let path = pathSplit.slice(0, i + 1).map(p => encodeURIComponent(p)).join('/');
-      if (path === '') {
-        path = '/';
-      }
-      this.$store.commit('setNodeToMoveDestination', path);
-      window.openNodeInMoveModal(event, path);
-    },
-    moveNode() {
-      this.$store.dispatch('moveNode', {
-          target: this.nodeToMove,
-          direction: this.destinationPath
-        })
-        .then(() => {
-          this.$bvModal.hide('move-modal');
-        })
-    }
-  }
-}
-</script>
-
-<style>
-#move-nodes .list-group-item {
-  /* reduced padding */
-  padding-top: .35rem;
-  padding-bottom: .35rem;
-}
-
-#move-nodes-wrapper {
-  max-height: 300px;
-  overflow-y: auto;
-}
-</style>
diff --git a/vospace-ui-frontend/src/components/modal/MoveOrCopyModal.vue b/vospace-ui-frontend/src/components/modal/MoveOrCopyModal.vue
new file mode 100644
index 0000000..2534b92
--- /dev/null
+++ b/vospace-ui-frontend/src/components/modal/MoveOrCopyModal.vue
@@ -0,0 +1,111 @@
+<!--
+  This file is part of vospace-ui
+  Copyright (C) 2021 Istituto Nazionale di Astrofisica
+  SPDX-License-Identifier: GPL-3.0-or-later
+-->
+<template>
+<b-modal id="move-or-copy-modal" :title="title" :okTitle="okTitle" @show="afterShow" @ok.prevent="moveOrCopyNodes" :ok-disabled="!writable" size="lg">
+  <ol class="breadcrumb">
+    <li class="breadcrumb-item" v-for="(item, i) in breadcrumbs" :key="i" :class="{ 'active' : item.active }">
+      <a href="#" @click.stop.prevent="breadcrumbClick(i)" v-if="!item.active">{{item.text}}</a>
+      <span v-if="item.active">{{item.text}}</span>
+    </li>
+  </ol>
+  <div id="move-or-copy-nodes-wrapper">
+    <div id="move-or-copy-nodes"></div>
+  </div>
+  <div v-if="selectedNotCopiableNodes.length > 0" class="mt-3">
+    <p><strong>Warning</strong>: following selected nodes can't be copied and will be ignored:</p>
+    <p>
+    <ul>
+      <li v-for="node in selectedNotCopiableNodes" :key="node">{{node}}</li>
+    </ul>
+    </p>
+  </div>
+  <div v-if="selectedNotMovableNodes.length > 0" class="mt-3">
+    <p><strong>Warning</strong>: following selected nodes can't be moved and will be ignored:</p>
+    <p>
+    <ul>
+      <li v-for="node in selectedNotMovableNodes" :key="node">{{node}}</li>
+    </ul>
+    </p>
+  </div>
+</b-modal>
+</template>
+
+<script>
+export default {
+  name: 'MoveOrCopyModal',
+  computed: {
+    nodesToMoveOrCopy() { return this.$store.state.nodesToMoveOrCopy },
+    destinationPath() { return this.$store.state.moveOrCopyDestination },
+    writable() { return this.$store.state.moveOrCopyDestinationWritable },
+    moveOrCopy() { return this.$store.state.moveOrCopy },
+    selectedNotMovableNodes() { return this.$store.state.selectedNotMovableNodes },
+    selectedNotCopiableNodes() { return this.$store.state.selectedNotCopiableNodes },
+    title() {
+      let text = this.moveOrCopy === 'move' ? 'Move ' : 'Copy ';
+      if (this.nodesToMoveOrCopy.length === 1) {
+        text += this.nodesToMoveOrCopy[0];
+      } else {
+        text += this.nodesToMoveOrCopy.length + ' nodes';
+      }
+      text += ' to ' + this.destinationPath;
+      return text;
+    },
+    okTitle() { return (this.moveOrCopy === 'move' ? 'Move nodes' : 'Copy nodes') },
+    breadcrumbs() {
+      let items = [];
+      if (this.destinationPath !== null) {
+        let pathSplit = this.destinationPath.split('/');
+        for (let i = 0; i < pathSplit.length; i++) {
+          items.push({
+            text: i === 0 ? 'ROOT' : pathSplit[i],
+            active: i === pathSplit.length - 1
+          });
+        }
+      }
+      return items;
+    }
+  },
+  methods: {
+    afterShow() {
+      // starts from parent path
+      let firstSelectedNode = this.nodesToMoveOrCopy[0];
+      this.$store.dispatch('openNodeInMoveOrCopyModal', firstSelectedNode.substring(0, firstSelectedNode.lastIndexOf('/')));
+    },
+    breadcrumbClick(i) {
+      let pathSplit = this.destinationPath.split('/');
+      let path = pathSplit.slice(0, i + 1).map(p => encodeURIComponent(p)).join('/');
+      if (path === '') {
+        path = '/';
+      }
+      this.$store.commit('setMoveOrCopyDestination', path);
+      window.openNodeInMoveOrCopyModal(event, path);
+    },
+    moveOrCopyNodes() {
+      this.$store.dispatch('moveOrCopyNodes', {
+          targets: this.nodesToMoveOrCopy,
+          direction: this.destinationPath,
+          keepBytes: this.moveOrCopy === 'copy'
+        })
+        .then(() => {
+          this.$bvModal.hide('move-or-copy-modal');
+        })
+    }
+  }
+}
+</script>
+
+<style>
+#move-or-copy-nodes .list-group-item {
+  /* reduced padding */
+  padding-top: .35rem;
+  padding-bottom: .35rem;
+}
+
+#move-or-copy-nodes-wrapper {
+  max-height: 300px;
+  overflow-y: auto;
+}
+</style>
diff --git a/vospace-ui-frontend/src/components/modal/RenameModal.vue b/vospace-ui-frontend/src/components/modal/RenameModal.vue
index 54bfc06..4b86e4b 100644
--- a/vospace-ui-frontend/src/components/modal/RenameModal.vue
+++ b/vospace-ui-frontend/src/components/modal/RenameModal.vue
@@ -54,9 +54,10 @@ export default {
         this.newNameError = "Name contains an illegal character. Following characters are not allowed: < > ? \" : \\ / | ' * `";
       } else {
         let parentPath = this.nodeToRename.substring(0, this.nodeToRename.lastIndexOf('/') + 1);
-        this.$store.dispatch('moveNode', {
-            target: parentPath + this.oldName,
-            direction: parentPath + this.newName
+        this.$store.dispatch('moveOrCopyNodes', {
+            targets: [parentPath + this.oldName],
+            direction: parentPath + this.newName,
+            keepBytes: false
           })
           .then(() => {
             this.$bvModal.hide('rename-modal');
diff --git a/vospace-ui-frontend/src/main.js b/vospace-ui-frontend/src/main.js
index 7ba1ed9..a25f814 100644
--- a/vospace-ui-frontend/src/main.js
+++ b/vospace-ui-frontend/src/main.js
@@ -36,13 +36,23 @@ window.renameNode = function(path) {
   vm.$bvModal.show('rename-modal');
 }
 window.moveNode = function(path) {
-  store.commit('setNodeToMove', path);
-  vm.$bvModal.show('move-modal');
+  moveOrCopy(path, 'move');
 }
-window.openNodeInMoveModal = function(event, path) {
+window.copyNode = function(path) {
+  moveOrCopy(path, 'copy');
+}
+window.openNodeInMoveOrCopyModal = function(event, path) {
   event.preventDefault();
   event.stopPropagation();
-  store.dispatch('openNodeInMoveModal', path);
+  store.dispatch('openNodeInMoveOrCopyModal', path);
+}
+
+function moveOrCopy(path, moveOrCopy) {
+  store.commit('setNodesToMoveOrCopy', [path]);
+  store.commit('setMoveOrCopy', moveOrCopy);
+  store.commit('setSelectedNotMovableNodes', []);
+  store.commit('setSelectedNotCopiableNodes', []);
+  vm.$bvModal.show('move-or-copy-modal');
 }
 
 export default {
diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js
index 21640ef..c2d82b9 100644
--- a/vospace-ui-frontend/src/store.js
+++ b/vospace-ui-frontend/src/store.js
@@ -29,7 +29,9 @@ export default new Vuex.Store({
     nodesLoading: false,
     asyncButtonEnabled: false,
     deleteButtonEnabled: false,
+    moveButtonEnabled: false,
     archiveButtonEnabled: false,
+    copyButtonEnabled: false,
     jobs: [],
     jobsLoading: true,
     lastJobsCheckTime: null,
@@ -43,9 +45,12 @@ export default new Vuex.Store({
       groupWrite: null
     },
     nodeToRename: null,
-    nodeToMove: null,
-    nodeToMoveDestination: null,
-    nodeToMoveDestinationWritable: false,
+    moveOrCopy: 'move',
+    nodesToMoveOrCopy: [],
+    selectedNotMovableNodes: [],
+    selectedNotCopiableNodes: [],
+    moveOrCopyDestination: null,
+    moveOrCopyDestinationWritable: false,
     archiveType: null,
     nodesToArchive: [],
     selectedNotArchivableNodes: []
@@ -72,9 +77,15 @@ export default new Vuex.Store({
     setDeleteButtonEnabled(state, value) {
       state.deleteButtonEnabled = value;
     },
+    setMoveButtonEnabled(state, value) {
+      state.moveButtonEnabled = value;
+    },
     setArchiveButtonEnabled(state, value) {
       state.archiveButtonEnabled = value;
     },
+    setCopyButtonEnabled(state, value) {
+      state.copyButtonEnabled = value;
+    },
     setJobs(state, jobs) {
       updateArray(state.jobs, jobs);
     },
@@ -93,6 +104,9 @@ export default new Vuex.Store({
     setSelectedUndeletableNodes(state, paths) {
       updateArray(state.selectedUndeletableNodes, paths);
     },
+    setSelectedNotCopiableNodes(state, paths) {
+      updateArray(state.selectedNotCopiableNodes, paths);
+    },
     setWritable(state, value) {
       state.writable = value;
     },
@@ -104,14 +118,20 @@ export default new Vuex.Store({
     setNodeToRename(state, path) {
       state.nodeToRename = path;
     },
-    setNodeToMove(state, path) {
-      state.nodeToMove = path;
+    setMoveOrCopy(state, value) {
+      state.moveOrCopy = value;
+    },
+    setNodesToMoveOrCopy(state, paths) {
+      updateArray(state.nodesToMoveOrCopy, paths);
+    },
+    setMoveOrCopyDestination(state, path) {
+      state.moveOrCopyDestination = path;
     },
-    setNodeToMoveDestination(state, path) {
-      state.nodeToMoveDestination = path;
+    setMoveOrCopyDestinationWritable(state, value) {
+      state.moveOrCopyDestinationWritable = value;
     },
-    setNodeToMoveDestinationWritable(state, value) {
-      state.nodeToMoveDestinationWritable = value;
+    setSelectedNotMovableNodes(state, paths) {
+      updateArray(state.selectedNotMovableNodes, paths);
     },
     setArchiveType(state, type) {
       state.archiveType = type;
@@ -147,7 +167,9 @@ export default new Vuex.Store({
     computeButtonsVisibility({ commit }) {
       commit('setAsyncButtonEnabled', document.querySelectorAll('#nodes input.async:checked').length > 0);
       commit('setDeleteButtonEnabled', document.querySelectorAll('#nodes input.deletable:checked').length > 0);
+      commit('setMoveButtonEnabled', document.querySelectorAll('#nodes input.deletable:checked').length > 0);
       commit('setArchiveButtonEnabled', document.querySelectorAll('#nodes input:not(.async):checked').length > 0);
+      commit('setCopyButtonEnabled', document.querySelectorAll('#nodes input:not(.async):checked').length > 0);
     },
     startAsyncRecallJob({ state, commit, dispatch }) {
       let asyncCheckboxes = document.querySelectorAll('#nodes input.async:checked');
@@ -215,24 +237,27 @@ export default new Vuex.Store({
           dispatch('setPath', state.path);
         });
     },
-    moveNode({ state, commit, dispatch }, data) {
-      client.moveNode(data)
-        .then(job => {
-          if (job.phase === 'COMPLETED') {
+    moveOrCopyNodes({ state, commit, dispatch }, data) {
+      client.moveOrCopyNodes(data)
+        .then(jobs => {
+          let uncompletedJobs = jobs.filter(j => j.phase !== 'COMPLETED');
+          if (uncompletedJobs.length === 0) {
             // Reload current node
             dispatch('setPath', state.path);
           } else {
-            main.showInfo('Move operation is taking some time and will be handled in background');
-            commit('addJob', job);
+            main.showInfo('The operation is taking some time and will be handled in background');
+            for (let job of uncompletedJobs) {
+              commit('addJob', job);
+            }
           }
         });
     },
-    openNodeInMoveModal({ state, commit }, path) {
-      commit('setNodeToMoveDestination', path);
-      client.getNodesForMove({ path, nodeToMove: state.nodeToMove })
+    openNodeInMoveOrCopyModal({ state, commit }, path) {
+      commit('setMoveOrCopyDestination', path);
+      client.getNodesForMoveOrCopy({ path, targets: state.nodesToMoveOrCopy })
         .then(res => {
-          commit('setNodeToMoveDestinationWritable', res.writable);
-          document.getElementById('move-nodes').outerHTML = res.html;
+          commit('setMoveOrCopyDestinationWritable', res.writable);
+          document.getElementById('move-or-copy-nodes').outerHTML = res.html;
         });
     },
     createArchive({ state, commit }) {
-- 
GitLab