diff --git a/.gitignore b/.gitignore index 865f8a01c498463f28ac3fec92c40ff4af8578f0..fcc5e236b06d253c4e053db22d0bd7b3fe6baefc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ **/dist/* .env.local nbactions.xml - +.env.development.local 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 c7bb9c48f643964c4e6a1a890ff2d6a5229f4117..eecc18833c2513bfe27b7c74e7271d071c90ff8b 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 @@ -2,8 +2,8 @@ package it.inaf.ia2.vospace.ui.client; import com.fasterxml.jackson.databind.ObjectMapper; import it.inaf.ia2.aa.data.User; -import it.inaf.ia2.vospace.ui.VOSpaceException; import it.inaf.ia2.vospace.ui.VOSpaceUiApplication; +import it.inaf.ia2.vospace.ui.exception.VOSpaceException; import java.io.IOException; import java.io.InputStream; import java.io.StringWriter; @@ -40,6 +40,9 @@ public class VOSpaceClient { @Value("${use-json}") private boolean useJson; + @Value("${vospace-authority}") + private String authority; + private static final ObjectMapper MAPPER = new ObjectMapper(); private final HttpClient httpClient; @@ -95,6 +98,19 @@ public class VOSpaceClient { return call(request, BodyHandlers.ofInputStream(), 200, res -> unmarshal(res, Transfer.class)).getProtocols(); } + public Node createNode(Node node) { + + String path = node.getUri().substring(("vos://" + authority).length()); + + HttpRequest request = getRequest("/nodes" + path) + .header("Accept", useJson ? "application/json" : "text/xml") + .header("Content-Type", useJson ? "application/json" : "text/xml") + .PUT(HttpRequest.BodyPublishers.ofString(marshal(node))) + .build(); + + return call(request, BodyHandlers.ofInputStream(), 200, res -> unmarshal(res, Node.class)); + } + private <T, U> U call(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, int expectedStatusCode, Function<T, U> responseHandler) { try { return httpClient.sendAsync(request, responseBodyHandler) 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 4e2abc5b8c8e46638a9d31e7826832f70ec45187..71dfa9b7d1e017229b4dc99cb6f7061827488a03 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 @@ -1,8 +1,11 @@ package it.inaf.ia2.vospace.ui.controller; import it.inaf.ia2.vospace.ui.client.VOSpaceClient; +import it.inaf.ia2.vospace.ui.exception.BadRequestException; import it.inaf.ia2.vospace.ui.service.NodesService; +import java.util.Map; import javax.servlet.http.HttpServletRequest; +import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.Protocol; import net.ivoa.xml.vospace.v2.Transfer; import org.springframework.beans.factory.annotation.Autowired; @@ -12,6 +15,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController @@ -64,6 +69,28 @@ public class NodesController { return new ResponseEntity<>(headers, HttpStatus.SEE_OTHER); } + @PostMapping(value = "/folder") + public void newFolder(@RequestBody Map<String, String> params) { + + String parentPath = getRequiredParam(params, "parentPath"); + if (!parentPath.startsWith("/")) { + parentPath = "/" + parentPath; + } + String name = getRequiredParam(params, "name"); + + ContainerNode node = new ContainerNode(); + node.setUri("vos://" + authority + parentPath + "/" + name); + + client.createNode(node); + } + + private String getRequiredParam(Map<String, String> params, String key) { + if (!params.containsKey(key)) { + throw new BadRequestException("Missing mandatory parameter " + key); + } + return params.get(key); + } + /** * Slash is a special character in defining REST endpoints and trying to * define a PathVariable containing slashes doesn't work, so the endpoint diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/BadRequestException.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/BadRequestException.java new file mode 100644 index 0000000000000000000000000000000000000000..d686851e037a5ef0dce0d9e89ecad17b64d381d5 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/BadRequestException.java @@ -0,0 +1,12 @@ +package it.inaf.ia2.vospace.ui.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.BAD_REQUEST) +public class BadRequestException extends VOSpaceException { + + public BadRequestException(String message) { + super(message); + } +} diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/VOSpaceException.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/VOSpaceException.java index 2b227f5cd5b27b4bd956a6bb2690a0b5e7aebb80..41e5639ddf5316329da26cf9df821b3b89e0de14 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/VOSpaceException.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/VOSpaceException.java @@ -1,4 +1,4 @@ -package it.inaf.ia2.vospace.ui; +package it.inaf.ia2.vospace.ui.exception; public class VOSpaceException extends RuntimeException { diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java index 0919783aa1fc300efea6bb315bac64c3f6b7f017..74254a4f76914a9a8fa8daee13a00bedeab661ed 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java @@ -1,6 +1,6 @@ package it.inaf.ia2.vospace.ui.service; -import it.inaf.ia2.vospace.ui.VOSpaceException; +import it.inaf.ia2.vospace.ui.exception.VOSpaceException; import java.util.Optional; import net.ivoa.xml.vospace.v2.Node; import net.ivoa.xml.vospace.v2.Property; diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java index 107d040e5670f38066f54559e01938eb32818b63..5cd931c097606454513fc6f4962ee319299a051a 100644 --- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/client/VOSpaceClientTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import org.mockito.MockedStatic; import org.mockito.Mockito; import static org.mockito.Mockito.mock; @@ -40,6 +41,7 @@ public class VOSpaceClientTest { try ( MockedStatic<HttpClient> staticMock = Mockito.mockStatic(HttpClient.class)) { staticMock.when(HttpClient::newBuilder).thenReturn(builder); voSpaceClient = new VOSpaceClient("http://localhost/vospace"); + ReflectionTestUtils.setField(voSpaceClient, "authority", "ia2.inaf.it!vospace"); } voSpaceClient.servletRequest = mock(HttpServletRequest.class); @@ -56,6 +58,24 @@ public class VOSpaceClientTest { assertEquals("vos://ia2.inaf.it!vospace/node1", node.getUri()); } + @Test + public void testCreateNode() { + + ReflectionTestUtils.setField(voSpaceClient, "useJson", false); + + CompletableFuture response = getMockedStreamResponseFuture(200, getResourceFileContent("node-response.xml")); + when(mockedHttpClient.sendAsync(argThat(request -> { + assertEquals("/vospace/nodes/mynode/newnode", request.uri().getPath()); + return true; + }), any())).thenReturn(response); + + ContainerNode newNode = new ContainerNode(); + newNode.setUri("vos://ia2.inaf.it!vospace/mynode/newnode"); + + ContainerNode responseNode = (ContainerNode) voSpaceClient.createNode(newNode); + assertEquals(newNode.getUri(), responseNode.getUri()); + } + protected static String getResourceFileContent(String fileName) { try ( InputStream in = VOSpaceClientTest.class.getClassLoader().getResourceAsStream(fileName)) { return new String(in.readAllBytes(), StandardCharsets.UTF_8); diff --git a/vospace-ui-backend/src/test/resources/node-response.xml b/vospace-ui-backend/src/test/resources/node-response.xml new file mode 100644 index 0000000000000000000000000000000000000000..c87b4aecd461ccfe16d59fba45d3b24a7f573835 --- /dev/null +++ b/vospace-ui-backend/src/test/resources/node-response.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<vos:node xmlns:vos="http://www.ivoa.net/xml/VOSpace/v2.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + busy="false" xsi:type="vos:ContainerNode" uri="vos://ia2.inaf.it!vospace/mynode/newnode"> + <vos:properties> + <vos:property uri="ivo://ivoa.net/vospace/core#btime">2021-01-15 18:24:03.749352</vos:property> + <vos:property uri="ivo://ivoa.net/vospace/core#groupwrite">group1</vos:property> + </vos:properties> +</vos:node> \ No newline at end of file diff --git a/vospace-ui-frontend/src/api/mock/index.js b/vospace-ui-frontend/src/api/mock/index.js index 7f031754583d083c5564d470f4767fc73124a6ff..d43470f1e50d93800bb77cdfda000d94c81dae61 100644 --- a/vospace-ui-frontend/src/api/mock/index.js +++ b/vospace-ui-frontend/src/api/mock/index.js @@ -45,5 +45,8 @@ export default { }, getUserInfo() { return fetch(user, false); + }, + createFolder() { + return fetch({}); } } diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js index 077a7c036464d6f746b794b21a3f71dc4e190274..a21e61fd14e8890fbcf78161a62459bec5142402 100644 --- a/vospace-ui-frontend/src/api/server/index.js +++ b/vospace-ui-frontend/src/api/server/index.js @@ -84,5 +84,20 @@ export default { }, data: paths }); + }, + createFolder(path, newFolderName) { + let url = BASE_API_URL + 'folder'; + return apiRequest({ + method: 'POST', + url: url, + withCredentials: true, + headers: { + 'Cache-Control': 'no-cache' + }, + data: { + parentPath: path, + name: newFolderName + } + }); } } diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue index 56c9563dea312fabf8ccd2844fe2d634acde0ffc..a2e17aebf39cdc4e047742324ff53802dbed7cdc 100644 --- a/vospace-ui-frontend/src/components/Main.vue +++ b/vospace-ui-frontend/src/components/Main.vue @@ -2,7 +2,7 @@ <div class="container"> <b-breadcrumb :items="breadcrumbs"></b-breadcrumb> <div class="mb-3"> - <b-button variant="success" class="mr-2" :disabled="true">New folder</b-button> + <b-button variant="success" class="mr-2" :disabled="false" v-b-modal.create-folder-modal>New folder</b-button> <b-button variant="success" class="mr-2" :disabled="true">Upload files</b-button> <b-button variant="primary" class="mr-2" v-if="tapeButtonEnabled" @click="startRecallFromTapeJob">Recall from tape</b-button> </div> @@ -28,16 +28,19 @@ <tbody id="nodes"></tbody> </table> </b-card> + <CreateFolderModal /> </div> </template> <script> import { BIconCheckSquare, BIconSquare } from 'bootstrap-vue' +import CreateFolderModal from './modal/CreateFolderModal.vue' export default { components: { BIconCheckSquare, - BIconSquare + BIconSquare, + CreateFolderModal }, computed: { breadcrumbs() { diff --git a/vospace-ui-frontend/src/components/modal/CreateFolderModal.vue b/vospace-ui-frontend/src/components/modal/CreateFolderModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..a3ff578a973b943bdb392fe0abb1568c2d309717 --- /dev/null +++ b/vospace-ui-frontend/src/components/modal/CreateFolderModal.vue @@ -0,0 +1,56 @@ +<template> +<b-modal id="create-folder-modal" title="Create folder" okTitle="Create" @show="reset" @ok="createFolder"> + <b-form inline> + <label class="w-25" for="new-folder-name-input">Folder name</label> + <b-form-input v-model.trim="newFolderName" id="new-folder-name-input" ref="newFolderNameInput" class="w-75" aria-describedby="new-folder-name-input-feedback" :state="newFolderNameState" v-on:input="resetError" + @keydown.native.enter="createFolder"> + </b-form-input> + <b-form-invalid-feedback id="new-folder-name-input-feedback" class="text-right">{{newFolderNameError}}</b-form-invalid-feedback> + </b-form> +</b-modal> +</template> + +<script> +export default { + data() { + return { + newFolderName: null, + newFolderNameError: null + } + }, + computed: { + newFolderNameState() { + if (this.newFolderNameError) { + return false; + } + return null; + } + }, + methods: { + reset() { + this.newFolderName = null; + this.resetError(); + }, + resetError() { + this.newFolderNameError = null; + }, + createFolder(event) { + // Prevent modal from closing + event.preventDefault(); + + if (!this.newFolderName) { + this.newFolderNameError = "Folder name is required"; + } else { + this.$store.dispatch('createFolder', this.newFolderName) + .then(() => { //res + //this.$store.commit('updateGroupsPanel', res); + this.$bvModal.hide('create-folder-modal'); + }) + .catch(res => { + this.newFolderNameError = res.message; + }); + } + } + } +} +</script> diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js index a4cb6813b50103fcf8a361a9f1da25330d0a626a..cbf3bb95ccd9cbaae5a296f3dd0a192aea9aacc3 100644 --- a/vospace-ui-frontend/src/store.js +++ b/vospace-ui-frontend/src/store.js @@ -80,6 +80,13 @@ export default new Vuex.Store({ loadUserInfo({ commit }) { client.getUserInfo() .then(res => commit('setUsername', res.username)); + }, + createFolder({ state, dispatch }, newFolderName) { + client.createFolder(state.path, newFolderName) + .then(() => { + // Reload current node + dispatch('setPath', state.path); + }); } } });