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 a4fb6569ac65b849cf4d876bec4c997481970ebd..2490d3155134deb9dc0bcb72ce4b13d2538e9b79 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 @@ -5,7 +5,11 @@ import it.inaf.ia2.vospace.ui.client.VOSpaceClient; import it.inaf.ia2.vospace.ui.data.ListNodeData; import it.inaf.ia2.vospace.ui.service.NodesService; import it.inaf.oats.vospace.datamodel.NodeUtils; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.Property; @@ -19,7 +23,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -51,13 +54,6 @@ public class NodesController extends BaseController { return ResponseEntity.ok(nodesService.generateNodesHtml(path, principal)); } - @DeleteMapping(value = {"/nodes", "/nodes/**"}) - public void deleteNode() { - String path = getPath("/nodes/"); - LOG.debug("deleteNode called for path {}", path); - client.deleteNode(path); - } - @GetMapping(value = "/download/**") public ResponseEntity<?> directDownload() { @@ -100,6 +96,32 @@ public class NodesController extends BaseController { client.createNode(node); } + @PostMapping(value = "/delete", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity<?> deleteNodes(@RequestBody List<String> nodesToDelete) { + + CompletableFuture[] deleteCalls = nodesToDelete.stream() + .map(nodeToDelete -> { + return CompletableFuture.runAsync(() -> { + LOG.debug("deleteNode called for path {}", nodeToDelete); + client.deleteNode(nodeToDelete); + }, Runnable::run); + }) + .collect(Collectors.toList()) + .toArray(CompletableFuture[]::new); + + Optional<RuntimeException> error = CompletableFuture.allOf(deleteCalls) + .handle((fn, ex) -> { + return Optional.ofNullable(ex == null ? null : new RuntimeException(ex)); + }).join(); + + if (error.isPresent()) { + throw error.get(); + } + + // All the nodes have been correctly deleted + return ResponseEntity.noContent().build(); + } + 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/NodesService.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java index f341a75883e3a15f6821e1a5313e00bb54be7d3e..0934c8d16138e14cb386a8b40d16db4764277cca 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java @@ -65,10 +65,14 @@ public class NodesService { return ""; } + boolean deletable = NodeUtils.checkIfWritable(node, user.getName(), user.getGroups()) && !nodeInfo.isSticky(); + String html = "<tr>"; html += "<td><input type=\"checkbox\" data-node=\"" + nodeInfo.getPath().replace("\"", "\\\"") + "\" "; if (nodeInfo.isAsyncTrans()) { html += "class=\"async\""; + } else if (deletable) { + html += "class=\"deletable\""; } html += "/></td>"; html += "<td>" + getIcon(nodeInfo) + getLink(nodeInfo, user) + "</td>"; @@ -76,7 +80,7 @@ public class NodesService { html += "<td>" + nodeInfo.getGroupRead() + "</td>"; html += "<td>" + nodeInfo.getGroupWrite() + "</td>"; html += "<td>"; - if (NodeUtils.checkIfWritable(node, user.getName(), user.getGroups()) && !nodeInfo.isSticky()) { + if (deletable) { html += "<span class=\"icon trash-icon pointer\" onclick=\"deleteNode('" + nodeInfo.getPath() + "')\"></span>"; } html += "</td>"; diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java index 79b0fada9c875c4b8b21bd05a3021fc594191c80..39e3863a578840147ef792f196675ee67457c515 100644 --- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java @@ -1,25 +1,41 @@ package it.inaf.ia2.vospace.ui.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import it.inaf.ia2.vospace.ui.client.VOSpaceClient; import it.inaf.ia2.vospace.ui.service.NodesService; +import java.util.Arrays; +import java.util.List; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import org.mockito.Mockito; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.springframework.web.util.NestedServletException; @SpringBootTest @AutoConfigureMockMvc public class NodesControllerTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @MockBean private NodesService nodesService; + @MockBean + private VOSpaceClient client; + @Autowired private MockMvc mockMvc; @@ -49,4 +65,37 @@ public class NodesControllerTest { verify(nodesService).generateNodesHtml(eq("/a/b/c"), any()); } + + @Test + public void testDeleteMultipleNodes() throws Exception { + + List<String> paths = Arrays.asList("/a/b/c", "/e/f/g"); + + mockMvc.perform(post("/delete") + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(paths))) + .andExpect(status().isNoContent()); + + verify(client, times(1)).deleteNode(eq("/a/b/c")); + verify(client, times(1)).deleteNode(eq("/e/f/g")); + } + + @Test + public void testErrorOnDelete() throws Exception { + + doThrow(new RuntimeException()) + .when(client).deleteNode(any()); + + boolean exception = false; + try { + mockMvc.perform(post("/delete") + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(Arrays.asList("/test")))) + .andReturn(); + } catch (NestedServletException ex) { + exception = true; + } + + assertTrue(exception); + } } diff --git a/vospace-ui-frontend/src/api/mock/data/nodes/root.json b/vospace-ui-frontend/src/api/mock/data/nodes/root.json index dcd4c04815c6d060abf98aa7579b38931f4392c2..6014d41b8781cdf24bfc7e2d5c4ed57c399f978a 100644 --- a/vospace-ui-frontend/src/api/mock/data/nodes/root.json +++ b/vospace-ui-frontend/src/api/mock/data/nodes/root.json @@ -1,4 +1,4 @@ { "writable": true, - "htmlTable": "<tbody id=\"nodes\"> <tr> <td><input type=\"checkbox\" data-node=\"/folder1\" /></td> <td> <span class=\"icon folder-icon\"></span> <a href=\"#/nodes/folder1\">folder1</a> </td> <td>0 B</td> <td>group1</td> <td>group2</td> <td> <span class=\"icon trash-icon pointer\" onclick=\"deleteNode('/folder1')\"></span> </td> </tr> <tr> <td><input type=\"checkbox\" data-node=\"/file1\" /></td> <td> <span class=\"icon file-icon\"></span> <a href=\"download/file1\">file1</a> </td> <td>12 MB</td> <td>group1</td> <td>group2</td> <td></td> </tr></tbody>" + "htmlTable": "<tbody id=\"nodes\"> <tr> <td><input type=\"checkbox\" class=\"deletable\" data-node=\"/folder1\" /></td> <td> <span class=\"icon folder-icon\"></span> <a href=\"#/nodes/folder1\">folder1</a> </td> <td>0 B</td> <td>group1</td> <td>group2</td> <td> <span class=\"icon trash-icon pointer\" onclick=\"deleteNode('/folder1')\"></span> </td> </tr> <tr> <td><input type=\"checkbox\" data-node=\"/file1\" /></td> <td> <span class=\"icon file-icon\"></span> <a href=\"download/file1\">file1</a> </td> <td>12 MB</td> <td>group1</td> <td>group2</td> <td></td> </tr></tbody>" } diff --git a/vospace-ui-frontend/src/api/mock/index.js b/vospace-ui-frontend/src/api/mock/index.js index e92cd1114526de3592eb3d63d6ef420d6526bac6..da818a1244c370383e099314882f89336d91deca 100644 --- a/vospace-ui-frontend/src/api/mock/index.js +++ b/vospace-ui-frontend/src/api/mock/index.js @@ -55,10 +55,10 @@ export default { uploadFile() { return fetch({}); }, - deleteNode() { + deleteNodes() { return fetch({}); }, keepalive() { - return fetch({}); + return fetch({}); } } diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js index 0cad25df45dfecb676b3c7638f9c41a9a545b6a4..23878170ddaacd4370fc4d8efa7899a2addbeee7 100644 --- a/vospace-ui-frontend/src/api/server/index.js +++ b/vospace-ui-frontend/src/api/server/index.js @@ -130,15 +130,16 @@ export default { } }) }, - deleteNode(path) { - let url = BASE_API_URL + 'nodes' + escapePath(path); + deleteNodes(paths) { + let url = BASE_API_URL + 'delete'; return apiRequest({ - method: 'DELETE', + method: 'POST', url: url, withCredentials: true, headers: { 'Cache-Control': 'no-cache' - } + }, + data: paths }, true, true); }, keepalive() { diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue index 3038c24d201c005f6a020af920a68018a5e32276..cc35967a89dc5fac3cb99d888fc06de7713fdf6c 100644 --- a/vospace-ui-frontend/src/components/Main.vue +++ b/vospace-ui-frontend/src/components/Main.vue @@ -5,6 +5,7 @@ <b-button variant="success" class="mr-2" :disabled="!writable" v-b-modal.create-folder-modal>New folder</b-button> <b-button variant="success" class="mr-2" :disabled="!writable" v-b-modal.upload-files-modal>Upload files</b-button> <b-button variant="primary" class="mr-2" v-if="asyncButtonEnabled" @click="startAsyncRecallJob">Async recall</b-button> + <b-button variant="danger" class="mr-2" v-if="deleteButtonEnabled" @click="deleteNodes">Delete</b-button> </div> <b-card> <table class="table b-table table-striped table-hover"> @@ -69,6 +70,9 @@ export default { asyncButtonEnabled() { return this.$store.state.asyncButtonEnabled; }, + deleteButtonEnabled() { + return this.$store.state.deleteButtonEnabled; + }, writable() { return this.$store.state.writable; } @@ -97,6 +101,15 @@ export default { }, startAsyncRecallJob() { this.$store.dispatch('startAsyncRecallJob'); + }, + deleteNodes() { + let deletableCheckboxes = document.querySelectorAll('#nodes input.deletable:checked'); + let paths = []; + for (let i = 0; i < deletableCheckboxes.length; i++) { + paths.push(deletableCheckboxes[i].getAttribute('data-node')); + } + this.$store.commit('setNodesToDelete', paths); + this.$bvModal.show('confirm-delete-modal'); } } } diff --git a/vospace-ui-frontend/src/components/modal/ConfirmDeleteModal.vue b/vospace-ui-frontend/src/components/modal/ConfirmDeleteModal.vue index fb403398a9330225f21ea80cf5178834622cf977..69c448edca0e003b87bdcdcb4ab0e2dfb02a756e 100644 --- a/vospace-ui-frontend/src/components/modal/ConfirmDeleteModal.vue +++ b/vospace-ui-frontend/src/components/modal/ConfirmDeleteModal.vue @@ -1,6 +1,11 @@ <template> -<b-modal id="confirm-delete-modal" title="Confirm delete" okTitle="Yes, delete" @ok="deleteNode" @hidden="reset" ok-variant="danger"> - <p>Do you really want to delete node at {{nodeToDelete}}?</p> +<b-modal id="confirm-delete-modal" title="Confirm delete" okTitle="Yes, delete" @ok="deleteNodes" @hidden="reset" ok-variant="danger"> + <p>Do you really want to delete the following nodes?</p> + <p> + <ul> + <li v-for="node in nodesToDelete" :key="node">{{node}}</li> + </ul> + </p> </b-modal> </template> @@ -8,17 +13,17 @@ export default { name: 'ConfirmDeleteModal', computed: { - nodeToDelete() { return this.$store.state.nodeToDelete } + nodesToDelete() { return this.$store.state.nodesToDelete } }, methods: { reset() { - this.$store.commit('setNodeToDelete', null); + this.$store.commit('setNodesToDelete', []); }, - deleteNode(event) { + deleteNodes(event) { // Prevent modal from closing event.preventDefault(); - this.$store.dispatch('deleteNode') + this.$store.dispatch('deleteNodes') .then(() => { this.$bvModal.hide('confirm-delete-modal'); }); diff --git a/vospace-ui-frontend/src/main.js b/vospace-ui-frontend/src/main.js index d93a3b3c1a0f45774607da83c6f76a42b88cb87f..4c7c315753a530134388dbe887e95e4f9fc4f3ef 100644 --- a/vospace-ui-frontend/src/main.js +++ b/vospace-ui-frontend/src/main.js @@ -19,7 +19,7 @@ let vm = new Vue({ }).$mount('#app') window.deleteNode = function(path) { - store.commit('setNodeToDelete', path); + store.commit('setNodesToDelete', [path]); vm.$bvModal.show('confirm-delete-modal'); } diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js index f1278db7c7f43fc1f22bbb697d088c0807992558..87f5478eaf1a3a7ffb8e4bd847fd3338dd99aff0 100644 --- a/vospace-ui-frontend/src/store.js +++ b/vospace-ui-frontend/src/store.js @@ -7,15 +7,25 @@ import main from './main'; Vue.use(Vuex); +function updateArray(oldArr, newArr) { + // empty the array + oldArr.splice(0, oldArr.length); + // fill again + for (let i = 0; i < newArr.length; i++) { + oldArr.push(newArr[i]); + } +} + export default new Vuex.Store({ state: { path: '', loading: true, asyncButtonEnabled: false, + deleteButtonEnabled: false, jobs: [], jobsLoading: true, user: 'anonymous', - nodeToDelete: null, + nodesToDelete: [], writable: false }, mutations: { @@ -31,13 +41,11 @@ export default new Vuex.Store({ setAsyncButtonEnabled(state, value) { state.asyncButtonEnabled = value; }, + setDeleteButtonEnabled(state, value) { + state.deleteButtonEnabled = value; + }, setJobs(state, jobs) { - // empty the array - state.jobs.splice(0, jobs.length); - // fill again - for (let i = 0; i < jobs.length; i++) { - state.jobs.push(jobs[i]); - } + updateArray(state.jobs, jobs); }, addJob(state, job) { state.jobs.push(job); @@ -48,8 +56,8 @@ export default new Vuex.Store({ setUsername(state, username) { state.user = username; }, - setNodeToDelete(state, path) { - state.nodeToDelete = path; + setNodesToDelete(state, paths) { + updateArray(state.nodesToDelete, paths); }, setWritable(state, value) { state.writable = value; @@ -65,14 +73,15 @@ export default new Vuex.Store({ let checkboxes = document.querySelectorAll('#nodes input[type="checkbox"]'); for (let i = 0; i < checkboxes.length; i++) { checkboxes[i].addEventListener('change', function() { - dispatch('computeButtonVisibility'); + dispatch('computeButtonsVisibility'); }); } - dispatch('computeButtonVisibility'); + dispatch('computeButtonsVisibility'); }); }, - computeButtonVisibility({ commit }) { + computeButtonsVisibility({ commit }) { commit('setAsyncButtonEnabled', document.querySelectorAll('#nodes input.async:checked').length > 0); + commit('setDeleteButtonEnabled', document.querySelectorAll('#nodes input.deletable:checked').length > 0); }, startAsyncRecallJob({ commit }) { let asyncCheckboxes = document.querySelectorAll('#nodes input.async:checked'); @@ -119,8 +128,8 @@ export default new Vuex.Store({ dispatch('setPath', state.path); }); }, - deleteNode({ state, dispatch }) { - client.deleteNode(state.nodeToDelete) + deleteNodes({ state, dispatch }) { + client.deleteNodes(state.nodesToDelete) .then(() => { // Reload current node dispatch('setPath', state.path);