Skip to content
Snippets Groups Projects
Commit bc01144d authored by Sonia Zorba's avatar Sonia Zorba
Browse files

Implemented multiple nodes deletion #3826

parent 6c1a3374
Branches
Tags
No related merge requests found
Pipeline #1096 passed
...@@ -5,7 +5,11 @@ import it.inaf.ia2.vospace.ui.client.VOSpaceClient; ...@@ -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.data.ListNodeData;
import it.inaf.ia2.vospace.ui.service.NodesService; import it.inaf.ia2.vospace.ui.service.NodesService;
import it.inaf.oats.vospace.datamodel.NodeUtils; import it.inaf.oats.vospace.datamodel.NodeUtils;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.ContainerNode;
import net.ivoa.xml.vospace.v2.Property; import net.ivoa.xml.vospace.v2.Property;
...@@ -19,7 +23,6 @@ import org.springframework.http.HttpHeaders; ...@@ -19,7 +23,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; 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.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
...@@ -51,13 +54,6 @@ public class NodesController extends BaseController { ...@@ -51,13 +54,6 @@ public class NodesController extends BaseController {
return ResponseEntity.ok(nodesService.generateNodesHtml(path, principal)); 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/**") @GetMapping(value = "/download/**")
public ResponseEntity<?> directDownload() { public ResponseEntity<?> directDownload() {
...@@ -100,6 +96,32 @@ public class NodesController extends BaseController { ...@@ -100,6 +96,32 @@ public class NodesController extends BaseController {
client.createNode(node); 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) { protected String getPath(String prefix) {
String requestURL = servletRequest.getRequestURL().toString(); String requestURL = servletRequest.getRequestURL().toString();
return NodeUtils.getPathFromRequestURLString(requestURL, prefix); return NodeUtils.getPathFromRequestURLString(requestURL, prefix);
......
...@@ -65,10 +65,14 @@ public class NodesService { ...@@ -65,10 +65,14 @@ public class NodesService {
return ""; return "";
} }
boolean deletable = NodeUtils.checkIfWritable(node, user.getName(), user.getGroups()) && !nodeInfo.isSticky();
String html = "<tr>"; String html = "<tr>";
html += "<td><input type=\"checkbox\" data-node=\"" + nodeInfo.getPath().replace("\"", "\\\"") + "\" "; html += "<td><input type=\"checkbox\" data-node=\"" + nodeInfo.getPath().replace("\"", "\\\"") + "\" ";
if (nodeInfo.isAsyncTrans()) { if (nodeInfo.isAsyncTrans()) {
html += "class=\"async\""; html += "class=\"async\"";
} else if (deletable) {
html += "class=\"deletable\"";
} }
html += "/></td>"; html += "/></td>";
html += "<td>" + getIcon(nodeInfo) + getLink(nodeInfo, user) + "</td>"; html += "<td>" + getIcon(nodeInfo) + getLink(nodeInfo, user) + "</td>";
...@@ -76,7 +80,7 @@ public class NodesService { ...@@ -76,7 +80,7 @@ public class NodesService {
html += "<td>" + nodeInfo.getGroupRead() + "</td>"; html += "<td>" + nodeInfo.getGroupRead() + "</td>";
html += "<td>" + nodeInfo.getGroupWrite() + "</td>"; html += "<td>" + nodeInfo.getGroupWrite() + "</td>";
html += "<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 += "<span class=\"icon trash-icon pointer\" onclick=\"deleteNode('" + nodeInfo.getPath() + "')\"></span>";
} }
html += "</td>"; html += "</td>";
......
package it.inaf.ia2.vospace.ui.controller; 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 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 org.junit.jupiter.api.Test;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; 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 static org.mockito.Mockito.verify;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; 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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.web.util.NestedServletException;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
public class NodesControllerTest { public class NodesControllerTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@MockBean @MockBean
private NodesService nodesService; private NodesService nodesService;
@MockBean
private VOSpaceClient client;
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
...@@ -49,4 +65,37 @@ public class NodesControllerTest { ...@@ -49,4 +65,37 @@ public class NodesControllerTest {
verify(nodesService).generateNodesHtml(eq("/a/b/c"), any()); 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);
}
} }
{ {
"writable": true, "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>"
} }
...@@ -55,7 +55,7 @@ export default { ...@@ -55,7 +55,7 @@ export default {
uploadFile() { uploadFile() {
return fetch({}); return fetch({});
}, },
deleteNode() { deleteNodes() {
return fetch({}); return fetch({});
}, },
keepalive() { keepalive() {
......
...@@ -130,15 +130,16 @@ export default { ...@@ -130,15 +130,16 @@ export default {
} }
}) })
}, },
deleteNode(path) { deleteNodes(paths) {
let url = BASE_API_URL + 'nodes' + escapePath(path); let url = BASE_API_URL + 'delete';
return apiRequest({ return apiRequest({
method: 'DELETE', method: 'POST',
url: url, url: url,
withCredentials: true, withCredentials: true,
headers: { headers: {
'Cache-Control': 'no-cache' 'Cache-Control': 'no-cache'
} },
data: paths
}, true, true); }, true, true);
}, },
keepalive() { keepalive() {
......
...@@ -5,6 +5,7 @@ ...@@ -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.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="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="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> </div>
<b-card> <b-card>
<table class="table b-table table-striped table-hover"> <table class="table b-table table-striped table-hover">
...@@ -69,6 +70,9 @@ export default { ...@@ -69,6 +70,9 @@ export default {
asyncButtonEnabled() { asyncButtonEnabled() {
return this.$store.state.asyncButtonEnabled; return this.$store.state.asyncButtonEnabled;
}, },
deleteButtonEnabled() {
return this.$store.state.deleteButtonEnabled;
},
writable() { writable() {
return this.$store.state.writable; return this.$store.state.writable;
} }
...@@ -97,6 +101,15 @@ export default { ...@@ -97,6 +101,15 @@ export default {
}, },
startAsyncRecallJob() { startAsyncRecallJob() {
this.$store.dispatch('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');
} }
} }
} }
......
<template> <template>
<b-modal id="confirm-delete-modal" title="Confirm delete" okTitle="Yes, delete" @ok="deleteNode" @hidden="reset" ok-variant="danger"> <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 node at {{nodeToDelete}}?</p> <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> </b-modal>
</template> </template>
...@@ -8,17 +13,17 @@ ...@@ -8,17 +13,17 @@
export default { export default {
name: 'ConfirmDeleteModal', name: 'ConfirmDeleteModal',
computed: { computed: {
nodeToDelete() { return this.$store.state.nodeToDelete } nodesToDelete() { return this.$store.state.nodesToDelete }
}, },
methods: { methods: {
reset() { reset() {
this.$store.commit('setNodeToDelete', null); this.$store.commit('setNodesToDelete', []);
}, },
deleteNode(event) { deleteNodes(event) {
// Prevent modal from closing // Prevent modal from closing
event.preventDefault(); event.preventDefault();
this.$store.dispatch('deleteNode') this.$store.dispatch('deleteNodes')
.then(() => { .then(() => {
this.$bvModal.hide('confirm-delete-modal'); this.$bvModal.hide('confirm-delete-modal');
}); });
......
...@@ -19,7 +19,7 @@ let vm = new Vue({ ...@@ -19,7 +19,7 @@ let vm = new Vue({
}).$mount('#app') }).$mount('#app')
window.deleteNode = function(path) { window.deleteNode = function(path) {
store.commit('setNodeToDelete', path); store.commit('setNodesToDelete', [path]);
vm.$bvModal.show('confirm-delete-modal'); vm.$bvModal.show('confirm-delete-modal');
} }
......
...@@ -7,15 +7,25 @@ import main from './main'; ...@@ -7,15 +7,25 @@ import main from './main';
Vue.use(Vuex); 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({ export default new Vuex.Store({
state: { state: {
path: '', path: '',
loading: true, loading: true,
asyncButtonEnabled: false, asyncButtonEnabled: false,
deleteButtonEnabled: false,
jobs: [], jobs: [],
jobsLoading: true, jobsLoading: true,
user: 'anonymous', user: 'anonymous',
nodeToDelete: null, nodesToDelete: [],
writable: false writable: false
}, },
mutations: { mutations: {
...@@ -31,13 +41,11 @@ export default new Vuex.Store({ ...@@ -31,13 +41,11 @@ export default new Vuex.Store({
setAsyncButtonEnabled(state, value) { setAsyncButtonEnabled(state, value) {
state.asyncButtonEnabled = value; state.asyncButtonEnabled = value;
}, },
setDeleteButtonEnabled(state, value) {
state.deleteButtonEnabled = value;
},
setJobs(state, jobs) { setJobs(state, jobs) {
// empty the array updateArray(state.jobs, jobs);
state.jobs.splice(0, jobs.length);
// fill again
for (let i = 0; i < jobs.length; i++) {
state.jobs.push(jobs[i]);
}
}, },
addJob(state, job) { addJob(state, job) {
state.jobs.push(job); state.jobs.push(job);
...@@ -48,8 +56,8 @@ export default new Vuex.Store({ ...@@ -48,8 +56,8 @@ export default new Vuex.Store({
setUsername(state, username) { setUsername(state, username) {
state.user = username; state.user = username;
}, },
setNodeToDelete(state, path) { setNodesToDelete(state, paths) {
state.nodeToDelete = path; updateArray(state.nodesToDelete, paths);
}, },
setWritable(state, value) { setWritable(state, value) {
state.writable = value; state.writable = value;
...@@ -65,14 +73,15 @@ export default new Vuex.Store({ ...@@ -65,14 +73,15 @@ export default new Vuex.Store({
let checkboxes = document.querySelectorAll('#nodes input[type="checkbox"]'); let checkboxes = document.querySelectorAll('#nodes input[type="checkbox"]');
for (let i = 0; i < checkboxes.length; i++) { for (let i = 0; i < checkboxes.length; i++) {
checkboxes[i].addEventListener('change', function() { 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('setAsyncButtonEnabled', document.querySelectorAll('#nodes input.async:checked').length > 0);
commit('setDeleteButtonEnabled', document.querySelectorAll('#nodes input.deletable:checked').length > 0);
}, },
startAsyncRecallJob({ commit }) { startAsyncRecallJob({ commit }) {
let asyncCheckboxes = document.querySelectorAll('#nodes input.async:checked'); let asyncCheckboxes = document.querySelectorAll('#nodes input.async:checked');
...@@ -119,8 +128,8 @@ export default new Vuex.Store({ ...@@ -119,8 +128,8 @@ export default new Vuex.Store({
dispatch('setPath', state.path); dispatch('setPath', state.path);
}); });
}, },
deleteNode({ state, dispatch }) { deleteNodes({ state, dispatch }) {
client.deleteNode(state.nodeToDelete) client.deleteNodes(state.nodesToDelete)
.then(() => { .then(() => {
// Reload current node // Reload current node
dispatch('setPath', state.path); dispatch('setPath', state.path);
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment