diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java index 34e53b4140049258dfe1faf50427639f4c1f2428..b1088d392b89050f0e6933d0034dbba9d103c378 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java @@ -24,7 +24,6 @@ import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodyHandlers; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Scanner; import java.util.concurrent.CompletionException; import java.util.concurrent.ForkJoinPool; diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java index 05992e8769e2a0a68c9c5d36944cbc81cf01d167..f7450ae8cb8e5018251b4a7769f05a94cd80522d 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java @@ -16,6 +16,7 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; +import net.ivoa.xml.uws.v1.JobSummary; import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.Node; import net.ivoa.xml.vospace.v2.Property; @@ -134,6 +135,21 @@ public class NodesController extends BaseController { return ResponseEntity.noContent().build(); } + @PostMapping(value = "/move") + public void moveNode(@RequestBody Map<String, Object> params) { + + String target = getRequiredParam(params, "target"); + String direction = getRequiredParam(params, "direction"); + + Transfer transfer = new Transfer(); + transfer.setTarget(target); + transfer.setDirection(direction); + + JobSummary job = client.startTransferJob(transfer); + + // TODO: polling + } + protected String getPath(String prefix) { String requestURL = servletRequest.getRequestURL().toString(); return NodeUtils.getPathFromRequestURLString(requestURL, prefix); diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java index b087e5fbd942befc8217bd1c05119baa61e96752..2d2d1200bd65aed593876a73e24f41e37a188492 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java @@ -130,18 +130,36 @@ public class NodesHtmlGenerator { private void addActionsCell(NodeInfo nodeInfo, Element row) { Element cell = row.appendElement("td"); + + Element dotsMenu = cell.appendElement("span"); + dotsMenu.attr("class", "dots-menu icon dots-menu-icon pointer"); + + Element dropdown = dotsMenu.appendElement("span"); + dropdown.attr("class", "dots-menu-content dropdown-menu"); + + String nodePathJs = makeJsArg(nodeInfo.getPath()); + if (nodeInfo.isWritable()) { - Element shareIcon = cell.appendElement("span"); - shareIcon.attr("class", "icon share-icon pointer"); - shareIcon.attr("onclick", "shareNode(" + makeJsArg(nodeInfo.getPath()) + Element shareBtn = dropdown.appendElement("button"); + shareBtn.text("Share"); + shareBtn.attr("type", "button"); + shareBtn.attr("class", "dropdown-item"); + shareBtn.attr("onclick", "shareNode(" + nodePathJs + "," + makeJsArg(nodeInfo.getGroupRead()) + "," + makeJsArg(nodeInfo.getGroupWrite()) + ")"); } if (nodeInfo.isDeletable()) { - cell.append(" "); - Element deleteIcon = cell.appendElement("span"); - deleteIcon.attr("class", "icon trash-icon pointer"); - deleteIcon.attr("onclick", "deleteNode(" + makeJsArg(nodeInfo.getPath()) + ")"); + Element renameBtn = dropdown.appendElement("button"); + renameBtn.text("Rename"); + renameBtn.attr("type", "button"); + renameBtn.attr("class", "dropdown-item"); + renameBtn.attr("onclick", "renameNode(" + nodePathJs + ")"); + + Element deleteBtn = dropdown.appendElement("button"); + deleteBtn.text("Delete"); + deleteBtn.attr("type", "button"); + deleteBtn.attr("class", "dropdown-item"); + deleteBtn.attr("onclick", "deleteNode(" + nodePathJs + ")"); } } diff --git a/vospace-ui-frontend/src/App.vue b/vospace-ui-frontend/src/App.vue index 8e6f9c61b9cfd6733320ca13e37e8d959cad0cbb..12d71e245a0a955a955a454b5c75b11d41ef27a3 100644 --- a/vospace-ui-frontend/src/App.vue +++ b/vospace-ui-frontend/src/App.vue @@ -47,6 +47,19 @@ export default { self.$store.dispatch('checkJobs'); } }, 1000); + + // Add event listener for dots menus + document.addEventListener('click', function(event) { + if (event.target.classList.contains('dots-menu')) { + event.target.classList.toggle('active') + } else { + if (event.target.closest('.dots-menu') === null) { + for (let menu of document.querySelectorAll('.dots-menu')) { + menu.classList.remove('active'); + } + } + } + }); } } </script> @@ -111,4 +124,22 @@ export default { .node-busy+.icon { margin-right: 3px; } + +.dots-menu { + position: relative; +} + +.dots-menu-content { + display: none; + position: absolute; + /*background-color: #f9f9f9; + min-width: 160px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + padding: 12px 16px; + z-index: 1;*/ +} + +.dots-menu.active .dots-menu-content { + display: block; +} </style> diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js index 92f6185a1e5206c16faf30fab098461d1e1ed611..5d9284030f262970c22f766aeda44f42d0425dcf 100644 --- a/vospace-ui-frontend/src/api/server/index.js +++ b/vospace-ui-frontend/src/api/server/index.js @@ -180,5 +180,17 @@ export default { }, data }, true, true); + }, + moveNode(data) { + let url = BASE_API_URL + 'move'; + return apiRequest({ + method: 'POST', + url: url, + withCredentials: true, + headers: { + 'Cache-Control': 'no-cache' + }, + data + }, true, true); } } diff --git a/vospace-ui-frontend/src/assets/css/fonts.css b/vospace-ui-frontend/src/assets/css/fonts.css index 29beec4331331351ab38c2bc0b8345933447f44d..bdd3cecc3715c30d026ea23f1f8115c78de4a96c 100644 --- a/vospace-ui-frontend/src/assets/css/fonts.css +++ b/vospace-ui-frontend/src/assets/css/fonts.css @@ -44,3 +44,7 @@ .person-icon { background-image: url("data:image/svg+xml,%3Csvg data-v-41be6633='' viewBox='0 0 16 16' width='1em' height='1em' focusable='false' role='img' aria-label='person fill' xmlns='http://www.w3.org/2000/svg' fill='currentColor' class='bi-person-fill mx-auto b-icon bi'%3E%3Cg data-v-41be6633=''%3E%3Cpath d='M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E"); } + +.dots-menu-icon { + background-image: url("data:image/svg+xml,%3Csvg data-v-41be6633='' viewBox='0 0 16 16' width='1em' height='1em' focusable='false' role='img' aria-label='three dots vertical' xmlns='http://www.w3.org/2000/svg' fill='currentColor' class='bi-three-dots-vertical mx-auto b-icon bi'%3E%3Cg data-v-41be6633=''%3E%3Cpath d='M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E"); +} diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue index 9edb15f7c1c3a6959f3a8157007971c0b0fb299c..c2be3ae3bb5ce2f38cdd1c0947bf5d5f93dfd220 100644 --- a/vospace-ui-frontend/src/components/Main.vue +++ b/vospace-ui-frontend/src/components/Main.vue @@ -39,6 +39,7 @@ <UploadFilesModal /> <ConfirmDeleteModal /> <ShareModal /> + <RenameModal /> </div> </template> @@ -48,6 +49,7 @@ import CreateFolderModal from './modal/CreateFolderModal.vue' import UploadFilesModal from './modal/UploadFilesModal.vue' import ConfirmDeleteModal from './modal/ConfirmDeleteModal.vue' import ShareModal from './modal/ShareModal.vue' +import RenameModal from './modal/RenameModal.vue' export default { components: { @@ -56,7 +58,8 @@ export default { CreateFolderModal, UploadFilesModal, ConfirmDeleteModal, - ShareModal + ShareModal, + RenameModal }, computed: { breadcrumbs() { diff --git a/vospace-ui-frontend/src/components/modal/RenameModal.vue b/vospace-ui-frontend/src/components/modal/RenameModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..a24b4f6a05f262c35f19edba3a4fbef51c2c6efe --- /dev/null +++ b/vospace-ui-frontend/src/components/modal/RenameModal.vue @@ -0,0 +1,71 @@ +<!-- + 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="rename-modal" :title="'Rename ' + nodeToRename" okTitle="Rename" @show="reset" @shown="afterShow" @ok.prevent="renameNode"> + <b-form inline> + <label class="w-25" for="new-name-input">New name</label> + <b-form-input v-model.trim="newName" id="new-name-input" ref="newNameInput" class="w-75" aria-describedby="new-name-input-feedback" :state="newNameState" v-on:input="resetError" @keydown.native.enter="renameNode"> + </b-form-input> + <b-form-invalid-feedback id="new-folder-name-input-feedback" class="text-right">{{newNameError}}</b-form-invalid-feedback> + </b-form> +</b-modal> +</template> + +<script> +export default { + name: 'RenameModal', + computed: { + nodeToRename() { return this.$store.state.nodeToRename }, + oldName() { return this.nodeToRename.substring(this.nodeToRename.lastIndexOf("/") + 1) }, + newNameState() { + if (this.newNameError) { + return false; + } + return null; + } + }, + data() { + return { + newName: this.oldName, + newNameError: null + } + }, + methods: { + afterShow: function() { + this.$refs.newNameInput.focus(); + }, + reset() { + this.newName = this.oldName; + this.resetError(); + }, + resetError() { + this.newNameError = null; + }, + renameNode() { + if (this.newName === this.oldName) { + return; + } + if (!this.newName) { + this.newNameError = "Name is required"; + } else if (/[<>?":\\/`|'*]/.test(this.newName)) { + this.newNameError = "Name contains an illegal character. Following characters are not allowed: < > ? \" : \\ / | ' * `"; + } else { + let parentPath = this.oldName.substring(0, this.oldName.lastIndexOf('/') + 1); + this.$store.dispatch('moveNode', { + target: parentPath + this.oldName, + direction: parentPath + this.newName + }) + .then(() => { + this.$bvModal.hide('rename-modal'); + }) + .catch(res => { + this.newFolderNameError = res.message; + }); + } + } + } +} +</script> diff --git a/vospace-ui-frontend/src/main.js b/vospace-ui-frontend/src/main.js index 8736a519a5c913ae7fbe2503be2145443e9ec650..d1caac178c7a6d168d56bef1ed36cfab97495f9b 100644 --- a/vospace-ui-frontend/src/main.js +++ b/vospace-ui-frontend/src/main.js @@ -31,6 +31,10 @@ window.shareNode = function(path, groupRead, groupWrite) { store.commit('setNodeToShare', { path, groupRead, groupWrite }); vm.$bvModal.show('share-modal'); } +window.renameNode = function(path) { + store.commit('setNodeToRename', path); + vm.$bvModal.show('rename-modal'); +} export default { showError(message) { diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js index bae41d1ad2a569c037498580f6f9169d2a684f72..d10ef32385e3a008f64e289a856ee7b0653feca1 100644 --- a/vospace-ui-frontend/src/store.js +++ b/vospace-ui-frontend/src/store.js @@ -38,7 +38,8 @@ export default new Vuex.Store({ path: null, groupRead: null, groupWrite: null - } + }, + nodeToRename: null }, mutations: { setLoading(state, loading) { @@ -81,6 +82,9 @@ export default new Vuex.Store({ state.nodeToShare.path = data.path; state.nodeToShare.groupRead = data.groupRead; state.nodeToShare.groupWrite = data.groupWrite; + }, + setNodeToRename(state, path) { + state.nodeToRename = path; } }, actions: { @@ -190,6 +194,13 @@ export default new Vuex.Store({ // Reload current node dispatch('setPath', state.path); }); + }, + moveNode({ state, dispatch }, data) { + client.moveNode(data) + .then(() => { + // Reload current node + dispatch('setPath', state.path); + }); } } });