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 9a0aebbd4c08b9e0e84b5038353ced4ed10c5341..31e00082d2c8fb1913d2a8319b78730c28eb1653 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 @@ -14,6 +14,7 @@ import java.net.http.HttpResponse.BodyHandlers; import java.util.Scanner; import java.util.concurrent.CompletionException; import java.util.function.Function; +import javax.xml.bind.JAXB; import net.ivoa.xml.vospace.v2.Node; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,6 +26,9 @@ public class VOSpaceClient { private static final Logger LOG = LoggerFactory.getLogger(VOSpaceClient.class); + @Value("${use-json}") + private boolean useJson; + private static final ObjectMapper MAPPER = new ObjectMapper(); private final HttpClient httpClient; @@ -45,8 +49,8 @@ public class VOSpaceClient { public Node getNode(String path) { - HttpRequest request = getRequest("/nodes/" + path) - .header("Accept", "application/json") + HttpRequest request = getRequest("/nodes" + path) + .header("Accept", useJson ? "application/json" : "text/xml") .build(); return call(request, BodyHandlers.ofInputStream(), 200, res -> parseJson(res, Node.class)); @@ -84,9 +88,13 @@ public class VOSpaceClient { return HttpRequest.newBuilder(URI.create(baseUrl + path)); } - private static <T> T parseJson(InputStream in, Class<T> type) { + private <T> T parseJson(InputStream in, Class<T> type) { try { - return MAPPER.readValue(in, type); + if (useJson) { + return MAPPER.readValue(in, type); + } else { + return JAXB.unmarshal(in, type); + } } catch (IOException ex) { LOG.error("Invalid JSON for class {}", type.getCanonicalName()); throw new UncheckedIOException(ex); 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 5b94a115a05770a940fdd7a89085c7998ec81fc7..af16e5202a176e391d2eafda7dabee7d5fe397da 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,7 +1,7 @@ package it.inaf.ia2.vospace.ui.controller; import it.inaf.ia2.vospace.ui.service.NodesService; -import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; @@ -18,16 +18,22 @@ public class NodesController { * This is the only API endpoint that returns HTML code instead of JSON. The * reason is that JavaScript frameworks are not very efficient in handling * very long lists and tables, so this part of the code is generated - * server-side. + * server-side. The content type is set to text/plain even if it is an HTML + * fragment to avoid browser parsing issues since it is not a complete HTML + * document. */ - @GetMapping(value = {"/nodes", "/nodes/{path}"}, produces = MediaType.TEXT_HTML_VALUE) - public void listNodes(@PathVariable(value = "path", required = false) String path, HttpServletResponse response) throws Exception { + @GetMapping(value = {"/nodes", "/nodes/**"}, produces = MediaType.TEXT_PLAIN_VALUE) + public String listNodes(HttpServletRequest request) throws Exception { - if (path == null || path.isBlank()) { - path = "/"; + String requestURL = request.getRequestURL().toString(); + String[] split = requestURL.split("/nodes/"); + + String path = "/"; + if (split.length == 2) { + path += split[1]; } - nodesService.generateNodesHtml(path, response.getOutputStream()); + return nodesService.generateNodesHtml(path); } @GetMapping(value = "/download/{path}") 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 524ea4fc2f25e8620bee7b47f54667243b158fbd..2edbb77063bbe38af44bdf6a4b6d5006af615d79 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 @@ -1,8 +1,9 @@ package it.inaf.ia2.vospace.ui.service; import it.inaf.ia2.vospace.ui.client.VOSpaceClient; -import java.io.OutputStream; -import java.io.PrintWriter; +import java.io.IOException; +import java.io.StringWriter; +import java.io.UncheckedIOException; import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.Node; import org.slf4j.Logger; @@ -22,20 +23,24 @@ public class NodesService { @Value("${vospace-authority}") private String authority; - public void generateNodesHtml(String path, OutputStream out) { + public String generateNodesHtml(String path) { Node node = client.getNode(path); - try ( PrintWriter pw = new PrintWriter(out)) { + try ( StringWriter sw = new StringWriter()) { if (node instanceof ContainerNode) { ContainerNode folder = (ContainerNode) node; - pw.println("<tbody id=\"nodes\">"); + sw.write("<tbody id=\"nodes\">"); for (Node child : folder.getNodes().getNode()) { - pw.println(getNodeHtml(child)); + sw.write(getNodeHtml(child)); } - pw.println("</tbody>"); + sw.write("</tbody>"); } + + return sw.toString(); + } catch (IOException ex) { + throw new UncheckedIOException(ex); } } @@ -67,7 +72,7 @@ public class NodesService { private String getLink(NodeInfo nodeInfo) { if (isDownloadable(nodeInfo)) { if ("vos:ContainerNode".equals(nodeInfo.getType())) { - return "<a href=\"#nodes" + nodeInfo.getPath() + "\">" + nodeInfo.getName() + "</a>"; + return "<a href=\"#/nodes" + nodeInfo.getPath() + "\">" + nodeInfo.getName() + "</a>"; } else { return "<a href=\"download" + nodeInfo.getPath() + "\" target=\"blank_\">" + nodeInfo.getName() + "</a>"; } diff --git a/vospace-ui-backend/src/main/resources/application.properties b/vospace-ui-backend/src/main/resources/application.properties index 934e28401708dbfcd3b590f1385ba466ca827601..1b0cb7d4cba4d692e30b91ff7dc704fc1f00894f 100644 --- a/vospace-ui-backend/src/main/resources/application.properties +++ b/vospace-ui-backend/src/main/resources/application.properties @@ -2,6 +2,7 @@ server.port=8085 vospace-backend-url=http://localhost:8083/vospace vospace-authority=example.com!vospace +use-json=true # For development only: spring.profiles.active=dev 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 new file mode 100644 index 0000000000000000000000000000000000000000..dc127f7c5e6645fd9b0ca5be55cbf8161d17698c --- /dev/null +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java @@ -0,0 +1,59 @@ +package it.inaf.ia2.vospace.ui.controller; + +import it.inaf.ia2.vospace.ui.service.NodesService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.eq; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.verify; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(MockitoExtension.class) +public class NodesControllerTest { + + @Mock + private NodesService nodesService; + + @InjectMocks + private NodesController controller; + + private MockMvc mockMvc; + + @BeforeEach + public void init() { + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + public void testListNodesEmpty() throws Exception { + + mockMvc.perform(get("/nodes")) + .andExpect(status().isOk()); + + verify(nodesService).generateNodesHtml(eq("/")); + } + + @Test + public void testListNodesRoot() throws Exception { + + mockMvc.perform(get("/nodes/")) + .andExpect(status().isOk()); + + verify(nodesService).generateNodesHtml(eq("/")); + } + + @Test + public void testListNodesComplexPath() throws Exception { + + mockMvc.perform(get("/nodes/a/b/c")) + .andExpect(status().isOk()); + + verify(nodesService).generateNodesHtml(eq("/a/b/c")); + } +} diff --git a/vospace-ui-frontend/src/App.vue b/vospace-ui-frontend/src/App.vue index 938c672de1302520b952ab38f2680ae70823b3fb..b9a76bea0c22ec31f09d35f0f43135b2eea6c5b3 100644 --- a/vospace-ui-frontend/src/App.vue +++ b/vospace-ui-frontend/src/App.vue @@ -1,20 +1,20 @@ <template> - <div id="app"> - <TopMenu /> - <div class="container"> - <router-view></router-view> - </div> - <div id="footer-fix"></div> - <footer class="text-center" id="site-footer"> - — Powered by <img alt="IA2 logo" src="./assets/ia2-logo-footer.png"> - <strong class="text-primary"><a href="http://www.ia2.inaf.it/" target="blank_">IA2</a></strong> — - </footer> - <div id="loading" v-if="loading"> - <div class="spinner-wrapper"> - <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner> - </div> +<div id="app"> + <TopMenu /> + <div class="container"> + <router-view></router-view> + </div> + <div id="footer-fix"></div> + <footer class="text-center" id="site-footer"> + — Powered by <img alt="IA2 logo" src="./assets/ia2-logo-footer.png"> + <strong class="text-primary"><a href="http://www.ia2.inaf.it/" target="blank_">IA2</a></strong> — + </footer> + <div id="loading" v-if="loading"> + <div class="spinner-wrapper"> + <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner> </div> </div> +</div> </template> <script> @@ -55,20 +55,20 @@ export default { } #footer-fix { - display: block; - width: 100%; - height: 100px; + display: block; + width: 100%; + height: 100px; } #site-footer { - color: #666; - border-top: 1px #e7e7e7 solid; - background-color: #f8f8f8; - padding: 5px 0; - position: fixed; - bottom: 0; - right: 0; - left: 0; + color: #666; + border-top: 1px #e7e7e7 solid; + background-color: #f8f8f8; + padding: 5px 0; + position: fixed; + bottom: 0; + right: 0; + left: 0; } #loading { diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js index 31c8eb7d8192fed385abf5bf863531b24dfbfa0a..a86c032ddd5f00dc7bc2d10b2afb7d44647df831 100644 --- a/vospace-ui-frontend/src/api/server/index.js +++ b/vospace-ui-frontend/src/api/server/index.js @@ -53,7 +53,7 @@ function loading(value) { export default { getNode(path) { - let url = BASE_API_URL + 'nodes' + path; + let url = BASE_API_URL + 'nodes/' + path; return apiRequest({ method: 'GET', url: url, diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue index 28ac7988173ebcd63aaac9e2c2e7e15caf0924c8..5263ff410915dd1e46b859fd7d3859e9612c8e38 100644 --- a/vospace-ui-frontend/src/components/Main.vue +++ b/vospace-ui-frontend/src/components/Main.vue @@ -1,13 +1,18 @@ <template> <div class="container"> + <b-breadcrumb :items="breadcrumbs"></b-breadcrumb> <b-card> <table class="table b-table table-striped table-hover"> <thead> <tr> <th id="checkboxes"> - <a href="#" @click.stop.prevent="selectAll"><BIconCheckSquare /></a> + <a href="#" @click.stop.prevent="selectAll"> + <BIconCheckSquare /> + </a> - <a href="#" @click.stop.prevent="deSelectAll"><BIconSquare /></a> + <a href="#" @click.stop.prevent="deSelectAll"> + <BIconSquare /> + </a> </th> <th>Name</th> <th>Size</th> @@ -22,25 +27,46 @@ </template> <script> -import client from 'api-client'; import { BIconCheckSquare, BIconSquare } from 'bootstrap-vue' export default { components: { - BIconCheckSquare, BIconSquare + BIconCheckSquare, + BIconSquare }, - mounted() { - client.getNode(this.$store.state.path) - .then(res => { - document.getElementById('nodes').outerHTML = res; - }); + computed: { + breadcrumbs() { + let items = [{ + text: 'ROOT', + href: '#/nodes/' + }]; + if (this.$store.state.path) { + let pathSplit = this.$store.state.path.split('/'); + for (let i = 0; i < pathSplit.length; i++) { + items.push({ + text: pathSplit[i], + href: '#/nodes/' + pathSplit.slice(0, i + 1).join('/') + }); + } + } + return items; + } + }, + created() { + this.loadNode(); + }, + watch: { + '$route.params.path': 'loadNode' }, methods: { + loadNode() { + this.$store.dispatch('setPath', this.$route.params.path); + }, selectAll() { this.selectInputs(true); }, deSelectAll() { - this.selectInputs(false); + this.selectInputs(false); }, selectInputs(value) { document.querySelectorAll('#nodes input').forEach(input => input.checked = value); @@ -56,9 +82,13 @@ export default { text-align: left; } +#nodes>tr>td:nth-child(2) { + white-space: nowrap; +} + th#checkboxes { /* Credits: https://stackoverflow.com/a/43615091/771431 */ - width:0.1%; + width: 0.1%; white-space: nowrap; } </style> diff --git a/vospace-ui-frontend/src/router.js b/vospace-ui-frontend/src/router.js index 355f50d65dfed4d0f05e07e549d78f8d6ad9b8e6..2bb82a7ef9c3788e0128010afd775fa2746be2ce 100644 --- a/vospace-ui-frontend/src/router.js +++ b/vospace-ui-frontend/src/router.js @@ -5,6 +5,10 @@ import Main from './components/Main.vue'; export default new VueRouter({ routes: [{ path: '/', + redirect: '/nodes/' + }, { + name: 'nodes', + path: '/nodes/:path(.*)', component: Main }] }) diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js index 464672b52388d1583acf79eb5fc412e832355318..6e4ed2cea1121cd173cfc150622c88d0f9a4e405 100644 --- a/vospace-ui-frontend/src/store.js +++ b/vospace-ui-frontend/src/store.js @@ -2,18 +2,35 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import client from 'api-client'; Vue.use(Vuex); export default new Vuex.Store({ state: { - path: '/', + //breadcrumbs: [], + path: '', loading: true }, mutations: { setLoading(state, loading) { state.loading = loading; + }, + setPath(state, value) { + if (!value) { + value = ''; + } + state.path = value; + //state.breadcrumbs = value.split('/') } }, - actions: {} + actions: { + setPath({ state, commit }, path) { + commit('setPath', path); + client.getNode(state.path) + .then(res => { + document.getElementById('nodes').outerHTML = res + }); + } + } });