diff --git a/scm-core/src/main/java/sonia/scm/repository/BrowserResult.java b/scm-core/src/main/java/sonia/scm/repository/BrowserResult.java index 212ce45f81..17c447eb87 100644 --- a/scm-core/src/main/java/sonia/scm/repository/BrowserResult.java +++ b/scm-core/src/main/java/sonia/scm/repository/BrowserResult.java @@ -1,19 +1,19 @@ /** * Copyright (c) 2010, Sebastian Sdorra * All rights reserved. - * + *

* Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + *

* 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. + * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + *

* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -24,13 +24,11 @@ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * + *

* http://bitbucket.org/sdorra/scm-manager - * */ - package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- @@ -40,12 +38,8 @@ import com.google.common.base.Objects; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; -import javax.xml.bind.annotation.XmlElement; -import javax.xml.bind.annotation.XmlElementWrapper; import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; -import java.util.Iterator; -import java.util.List; //~--- JDK imports ------------------------------------------------------------ @@ -56,224 +50,56 @@ import java.util.List; */ @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "browser-result") -public class BrowserResult implements Iterable, Serializable -{ +public class BrowserResult implements Serializable { - /** Field description */ - private static final long serialVersionUID = 2818662048045182761L; + private String revision; + private FileObject file; - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - */ - public BrowserResult() {} - - /** - * Constructs ... - * - * - * @param revision - * @param tag - * @param branch - * @param files - */ - public BrowserResult(String revision, String tag, String branch, - List files) - { - this.revision = revision; - this.tag = tag; - this.branch = branch; - this.files = files; + public BrowserResult() { } - //~--- methods -------------------------------------------------------------- + public BrowserResult(String revision, FileObject file) { + this.revision = revision; + this.file = file; + } + + public String getRevision() { + return revision; + } + + public FileObject getFile() { + return file; + } - /** - * {@inheritDoc} - * - * - * @param obj - * - * @return - */ @Override - public boolean equals(Object obj) - { - if (obj == null) - { + public boolean equals(Object obj) { + if (obj == null) { return false; } - if (getClass() != obj.getClass()) - { + if (getClass() != obj.getClass()) { return false; } final BrowserResult other = (BrowserResult) obj; return Objects.equal(revision, other.revision) - && Objects.equal(tag, other.tag) - && Objects.equal(branch, other.branch) - && Objects.equal(files, other.files); + && Objects.equal(file, other.file); } - /** - * {@inheritDoc} - * - * - * @return - */ @Override - public int hashCode() - { - return Objects.hashCode(revision, tag, branch, files); + public int hashCode() { + return Objects.hashCode(revision, file); } - /** - * Method description - * - * - * @return - */ + @Override - public Iterator iterator() - { - Iterator it = null; - - if (files != null) - { - it = files.iterator(); - } - - return it; - } - - /** - * {@inheritDoc} - * - * - * @return - */ - @Override - public String toString() - { - //J- + public String toString() { return MoreObjects.toStringHelper(this) - .add("revision", revision) - .add("tag", tag) - .add("branch", branch) - .add("files", files) - .toString(); - //J+ + .add("revision", revision) + .add("files", file) + .toString(); } - //~--- get methods ---------------------------------------------------------- - /** - * Method description - * - * - * @return - */ - public String getBranch() - { - return branch; - } - - /** - * Method description - * - * - * @return - */ - public List getFiles() - { - return files; - } - - /** - * Method description - * - * - * @return - */ - public String getRevision() - { - return revision; - } - - /** - * Method description - * - * - * @return - */ - public String getTag() - { - return tag; - } - - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param branch - */ - public void setBranch(String branch) - { - this.branch = branch; - } - - /** - * Method description - * - * - * @param files - */ - public void setFiles(List files) - { - this.files = files; - } - - /** - * Method description - * - * - * @param revision - */ - public void setRevision(String revision) - { - this.revision = revision; - } - - /** - * Method description - * - * - * @param tag - */ - public void setTag(String tag) - { - this.tag = tag; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private String branch; - - /** Field description */ - @XmlElement(name = "file") - @XmlElementWrapper(name = "files") - private List files; - - /** Field description */ - private String revision; - - /** Field description */ - private String tag; } diff --git a/scm-core/src/main/java/sonia/scm/repository/FileObject.java b/scm-core/src/main/java/sonia/scm/repository/FileObject.java index 5279921257..7dedebb13a 100644 --- a/scm-core/src/main/java/sonia/scm/repository/FileObject.java +++ b/scm-core/src/main/java/sonia/scm/repository/FileObject.java @@ -33,10 +33,9 @@ package sonia.scm.repository; -//~--- non-JDK imports -------------------------------------------------------- - import com.google.common.base.MoreObjects; import com.google.common.base.Objects; +import com.google.common.base.Strings; import sonia.scm.LastModifiedAware; import javax.xml.bind.annotation.XmlAccessType; @@ -44,8 +43,11 @@ import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; -//~--- JDK imports ------------------------------------------------------------ +import static java.util.Collections.unmodifiableCollection; /** * The FileObject represents a file or a directory in a repository. @@ -181,6 +183,22 @@ public class FileObject implements LastModifiedAware, Serializable return path; } + /** + * Returns the parent path of the file. + * + * @return parent path + */ + public String getParentPath() { + if (Strings.isNullOrEmpty(path)) { + return null; + } + int index = path.lastIndexOf('/'); + if (index > 0) { + return path.substring(0, index); + } + return ""; + } + /** * Return sub repository informations or null if the file is not * sub repository. @@ -284,6 +302,22 @@ public class FileObject implements LastModifiedAware, Serializable this.subRepository = subRepository; } + public Collection getChildren() { + return unmodifiableCollection(children); + } + + public void setChildren(List children) { + this.children = new ArrayList<>(children); + } + + public void addChild(FileObject child) { + this.children.add(child); + } + + public boolean hasChildren() { + return !children.isEmpty(); + } + //~--- fields --------------------------------------------------------------- /** file description */ @@ -307,4 +341,6 @@ public class FileObject implements LastModifiedAware, Serializable /** sub repository informations */ @XmlElement(name = "subrepository") private SubRepository subRepository; + + private Collection children = new ArrayList<>(); } diff --git a/scm-core/src/main/java/sonia/scm/repository/PreProcessorUtil.java b/scm-core/src/main/java/sonia/scm/repository/PreProcessorUtil.java index 2a1d9c0340..e64979dde6 100644 --- a/scm-core/src/main/java/sonia/scm/repository/PreProcessorUtil.java +++ b/scm-core/src/main/java/sonia/scm/repository/PreProcessorUtil.java @@ -161,11 +161,21 @@ public class PreProcessorUtil { if (logger.isTraceEnabled()) { - logger.trace("prepare browser result of repository {} for return", - repository.getName()); + logger.trace("prepare browser result of repository {} for return", repository.getName()); } - handlePreProcessForIterable(repository, result,fileObjectPreProcessorFactorySet, fileObjectPreProcessorSet); + PreProcessorHandler handler = new PreProcessorHandler<>(fileObjectPreProcessorFactorySet, fileObjectPreProcessorSet, repository); + handlePreProcessorForFileObject(handler, result.getFile()); + } + + private void handlePreProcessorForFileObject(PreProcessorHandler handler, FileObject fileObject) { + if (fileObject.isDirectory()) { + for (FileObject child : fileObject.getChildren()) { + handlePreProcessorForFileObject(handler, child); + } + } + handler.callPreProcessorFactories(fileObject); + handler.callPreProcessors(fileObject); } /** diff --git a/scm-core/src/main/java/sonia/scm/repository/api/BrowseCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/BrowseCommandBuilder.java index 3e5166c2f4..e308be6da4 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/BrowseCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/BrowseCommandBuilder.java @@ -38,11 +38,11 @@ package sonia.scm.repository.api; import com.google.common.base.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.NotFoundException; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; -import sonia.scm.repository.FileObjectNameComparator; import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryCacheKey; @@ -51,8 +51,6 @@ import sonia.scm.repository.spi.BrowseCommandRequest; import java.io.IOException; import java.io.Serializable; -import java.util.Collections; -import java.util.List; //~--- JDK imports ------------------------------------------------------------ @@ -179,14 +177,6 @@ public final class BrowseCommandBuilder if (!disablePreProcessors && (result != null)) { preProcessorUtil.prepareForReturn(repository, result); - - List fileObjects = result.getFiles(); - - if (fileObjects != null) - { - Collections.sort(fileObjects, FileObjectNameComparator.instance); - result.setFiles(fileObjects); - } } return result; diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommand.java index ee37d6243e..69d7080018 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommand.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommand.java @@ -35,6 +35,7 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- +import sonia.scm.NotFoundException; import sonia.scm.repository.BrowserResult; import java.io.IOException; diff --git a/scm-core/src/test/java/sonia/scm/repository/FileObjectTest.java b/scm-core/src/test/java/sonia/scm/repository/FileObjectTest.java new file mode 100644 index 0000000000..bbd9d0d483 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/FileObjectTest.java @@ -0,0 +1,32 @@ +package sonia.scm.repository; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class FileObjectTest { + + @Test + public void getParentPath() { + FileObject file = create("a/b/c"); + assertEquals("a/b", file.getParentPath()); + } + + @Test + public void getParentPathWithoutParent() { + FileObject file = create("a"); + assertEquals("", file.getParentPath()); + } + + @Test + public void getParentPathOfRoot() { + FileObject file = create(""); + assertNull(file.getParentPath()); + } + + private FileObject create(String path) { + FileObject file = new FileObject(); + file.setPath(path); + return file; + } +} diff --git a/scm-it/src/test/java/sonia/scm/it/I18nServletITCase.java b/scm-it/src/test/java/sonia/scm/it/I18nServletITCase.java new file mode 100644 index 0000000000..6597880665 --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/I18nServletITCase.java @@ -0,0 +1,19 @@ +package sonia.scm.it; + +import org.junit.Test; +import sonia.scm.it.utils.ScmRequests; + +import static org.assertj.core.api.Assertions.assertThat; + +public class I18nServletITCase { + + @Test + public void shouldGetCollectedPluginTranslations() { + ScmRequests.start() + .requestPluginTranslations("de") + .assertStatusCode(200) + .assertSingleProperty(value -> assertThat(value).isNotNull(), "scm-git-plugin") + .assertSingleProperty(value -> assertThat(value).isNotNull(), "scm-hg-plugin") + .assertSingleProperty(value -> assertThat(value).isNotNull(), "scm-svn-plugin"); + } +} diff --git a/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java b/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java index 334274b7b0..66ebc57c90 100644 --- a/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java @@ -197,7 +197,7 @@ public class RepositoryAccessITCase { .then() .statusCode(HttpStatus.SC_OK) .extract() - .path("_embedded.files.find{it.name=='a.txt'}._links.self.href"); + .path("_embedded.children.find{it.name=='a.txt'}._links.self.href"); given() .when() @@ -212,7 +212,7 @@ public class RepositoryAccessITCase { .then() .statusCode(HttpStatus.SC_OK) .extract() - .path("_embedded.files.find{it.name=='subfolder'}._links.self.href"); + .path("_embedded.children.find{it.name=='subfolder'}._links.self.href"); String selfOfSubfolderUrl = given() .when() .get(subfolderSourceUrl) @@ -227,7 +227,7 @@ public class RepositoryAccessITCase { .then() .statusCode(HttpStatus.SC_OK) .extract() - .path("_embedded.files[0]._links.self.href"); + .path("_embedded.children[0]._links.self.href"); given() .when() .get(subfolderContentUrl) diff --git a/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java index 69c79c37bf..14caa57beb 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java @@ -2,11 +2,13 @@ package sonia.scm.it.utils; import io.restassured.RestAssured; import io.restassured.response.Response; +import org.junit.Assert; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.user.User; import sonia.scm.web.VndMediaType; +import java.net.ConnectException; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -52,7 +54,12 @@ public class ScmRequests { setUsername(username); setPassword(password); return new ChangePasswordResponse<>(applyPUTRequest(RestUtil.REST_BASE_URL.resolve("users/"+userPathParam+"/password").toString(), VndMediaType.PASSWORD_OVERWRITE, TestData.createPasswordChangeJson(password,newPassword)), null); + } + @SuppressWarnings("unchecked") + public ModelResponse requestPluginTranslations(String language) { + Response response = applyGETRequest(RestUtil.BASE_URL.resolve("locales/" + language + "/plugins.json").toString()); + return new ModelResponse(response, null); } /** @@ -75,10 +82,12 @@ public class ScmRequests { * @return the response of the GET request using the given link */ private Response applyGETRequestFromLinkWithParams(Response response, String linkPropertyName, String params) { - return applyGETRequestWithQueryParams(response + String url = response .then() .extract() - .path(linkPropertyName), params); + .path(linkPropertyName); + Assert.assertNotNull("no url found for link " + linkPropertyName, url); + return applyGETRequestWithQueryParams(url, params); } /** @@ -90,6 +99,11 @@ public class ScmRequests { */ private Response applyGETRequestWithQueryParams(String url, String params) { LOG.info("GET {}", url); + if (username == null || password == null){ + return RestAssured.given() + .when() + .get(url + params); + } return RestAssured.given() .auth().preemptive().basic(username, password) .when() @@ -249,11 +263,11 @@ public class ScmRequests { } public ChangesetsResponse requestFileHistory(String fileName) { - return new ChangesetsResponse<>(applyGETRequestFromLink(response, "_embedded.files.find{it.name=='" + fileName + "'}._links.history.href"), this); + return new ChangesetsResponse<>(applyGETRequestFromLink(response, "_embedded.children.find{it.name=='" + fileName + "'}._links.history.href"), this); } public SourcesResponse requestSelf(String fileName) { - return new SourcesResponse<>(applyGETRequestFromLink(response, "_embedded.files.find{it.name=='" + fileName + "'}._links.self.href"), this); + return new SourcesResponse<>(applyGETRequestFromLink(response, "_embedded.children.find{it.name=='" + fileName + "'}._links.self.href"), this); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java index 2be4c34fb8..6839ff405c 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java @@ -35,9 +35,9 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.google.common.collect.Maps; -import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectLoader; @@ -106,6 +106,7 @@ public class GitBrowseCommand extends AbstractGitCommand logger.debug("try to create browse result for {}", request); BrowserResult result; + org.eclipse.jgit.lib.Repository repo = open(); ObjectId revId; @@ -120,7 +121,7 @@ public class GitBrowseCommand extends AbstractGitCommand if (revId != null) { - result = getResult(repo, request, revId); + result = new BrowserResult(revId.getName(), getEntry(repo, request, revId)); } else { @@ -133,8 +134,7 @@ public class GitBrowseCommand extends AbstractGitCommand logger.warn("coul not find head of repository, empty?"); } - result = new BrowserResult(Constants.HEAD, null, null, - Collections.EMPTY_LIST); + result = new BrowserResult(Constants.HEAD, createEmtpyRoot()); } return result; @@ -142,6 +142,14 @@ public class GitBrowseCommand extends AbstractGitCommand //~--- methods -------------------------------------------------------------- + private FileObject createEmtpyRoot() { + FileObject fileObject = new FileObject(); + fileObject.setName(""); + fileObject.setPath(""); + fileObject.setDirectory(true); + return fileObject; + } + /** * Method description * @@ -157,68 +165,52 @@ public class GitBrowseCommand extends AbstractGitCommand private FileObject createFileObject(org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException { - FileObject file; - try + FileObject file = new FileObject(); + + String path = treeWalk.getPathString(); + + file.setName(treeWalk.getNameString()); + file.setPath(path); + + SubRepository sub = null; + + if (!request.isDisableSubRepositoryDetection()) { - file = new FileObject(); + sub = getSubRepository(repo, revId, path); + } - String path = treeWalk.getPathString(); + if (sub != null) + { + logger.trace("{} seems to be a sub repository", path); + file.setDirectory(true); + file.setSubRepository(sub); + } + else + { + ObjectLoader loader = repo.open(treeWalk.getObjectId(0)); - file.setName(treeWalk.getNameString()); - file.setPath(path); + file.setDirectory(loader.getType() == Constants.OBJ_TREE); + file.setLength(loader.getSize()); - SubRepository sub = null; - - if (!request.isDisableSubRepositoryDetection()) + // don't show message and date for directories to improve performance + if (!file.isDirectory() &&!request.isDisableLastCommit()) { - sub = getSubRepository(repo, revId, path); - } + logger.trace("fetch last commit for {} at {}", path, revId.getName()); + RevCommit commit = getLatestCommit(repo, revId, path); - if (sub != null) - { - logger.trace("{} seems to be a sub repository", path); - file.setDirectory(true); - file.setSubRepository(sub); - } - else - { - ObjectLoader loader = repo.open(treeWalk.getObjectId(0)); - - file.setDirectory(loader.getType() == Constants.OBJ_TREE); - file.setLength(loader.getSize()); - - // don't show message and date for directories to improve performance - if (!file.isDirectory() &&!request.isDisableLastCommit()) + if (commit != null) { - logger.trace("fetch last commit for {} at {}", path, revId.getName()); - - RevCommit commit = getLatestCommit(repo, revId, path); - - if (commit != null) - { - file.setLastModified(GitUtil.getCommitTime(commit)); - file.setDescription(commit.getShortMessage()); - } - else if (logger.isWarnEnabled()) - { - logger.warn("could not find latest commit for {} on {}", path, - revId); - } + file.setLastModified(GitUtil.getCommitTime(commit)); + file.setDescription(commit.getShortMessage()); + } + else if (logger.isWarnEnabled()) + { + logger.warn("could not find latest commit for {} on {}", path, + revId); } } } - catch (MissingObjectException ex) - { - file = null; - logger.error("could not fetch object for id {}", revId); - - if (logger.isTraceEnabled()) - { - logger.trace("could not fetch object", ex); - } - } - return file; } @@ -264,22 +256,19 @@ public class GitBrowseCommand extends AbstractGitCommand return result; } - private BrowserResult getResult(org.eclipse.jgit.lib.Repository repo, - BrowseCommandRequest request, ObjectId revId) - throws IOException { - BrowserResult result = null; + private FileObject getEntry(org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId) throws IOException { RevWalk revWalk = null; TreeWalk treeWalk = null; - try - { - if (logger.isDebugEnabled()) - { - logger.debug("load repository browser for revision {}", revId.name()); - } + FileObject result; + + try { + logger.debug("load repository browser for revision {}", revId.name()); treeWalk = new TreeWalk(repo); - treeWalk.setRecursive(request.isRecursive()); + if (!isRootRequest(request)) { + treeWalk.setFilter(PathFilter.create(request.getPath())); + } revWalk = new RevWalk(repo); RevTree tree = revWalk.parseTree(revId); @@ -290,65 +279,20 @@ public class GitBrowseCommand extends AbstractGitCommand } else { - logger.error("could not find tree for {}", revId.name()); + throw new IllegalStateException("could not find tree for " + revId.name()); } - result = new BrowserResult(); - - List files = Lists.newArrayList(); - - String path = request.getPath(); - - if (Util.isEmpty(path)) - { - while (treeWalk.next()) - { - FileObject fo = createFileObject(repo, request, revId, treeWalk); - - if (fo != null) - { - files.add(fo); - } - } - } - else - { - String[] parts = path.split("/"); - int current = 0; - int limit = parts.length; - - while (treeWalk.next()) - { - String name = treeWalk.getNameString(); - - if (current >= limit) - { - String p = treeWalk.getPathString(); - - if (p.split("/").length > limit) - { - FileObject fo = createFileObject(repo, request, revId, treeWalk); - - if (fo != null) - { - files.add(fo); - } - } - } - else if (name.equalsIgnoreCase(parts[current])) - { - current++; - - if (!request.isRecursive()) - { - treeWalk.enterSubtree(); - } - } + if (isRootRequest(request)) { + result = createEmtpyRoot(); + findChildren(result, repo, request, revId, treeWalk); + } else { + result = findFirstMatch(repo, request, revId, treeWalk); + if ( result.isDirectory() ) { + treeWalk.enterSubtree(); + findChildren(result, repo, request, revId, treeWalk); } } - result.setFiles(files); - result.setRevision(revId.getName()); } finally { @@ -359,6 +303,60 @@ public class GitBrowseCommand extends AbstractGitCommand return result; } + private boolean isRootRequest(BrowseCommandRequest request) { + return Strings.isNullOrEmpty(request.getPath()) || "/".equals(request.getPath()); + } + + private FileObject findChildren(FileObject parent, org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException, NotFoundException { + List files = Lists.newArrayList(); + while (treeWalk.next()) + { + + FileObject fileObject = createFileObject(repo, request, revId, treeWalk); + if (!fileObject.getPath().startsWith(parent.getPath())) { + parent.setChildren(files); + return fileObject; + } + + files.add(fileObject); + + if (request.isRecursive() && fileObject.isDirectory()) { + treeWalk.enterSubtree(); + FileObject rc = findChildren(fileObject, repo, request, revId, treeWalk); + if (rc != null) { + files.add(rc); + } + } + } + + parent.setChildren(files); + + return null; + } + + private FileObject findFirstMatch(org.eclipse.jgit.lib.Repository repo, + BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException, NotFoundException { + String[] pathElements = request.getPath().split("/"); + int currentDepth = 0; + int limit = pathElements.length; + + while (treeWalk.next()) { + String name = treeWalk.getNameString(); + + if (name.equalsIgnoreCase(pathElements[currentDepth])) { + currentDepth++; + + if (currentDepth >= limit) { + return createFileObject(repo, request, revId, treeWalk); + } else { + treeWalk.enterSubtree(); + } + } + } + + throw new NotFoundException("file", request.getPath()); + } + @SuppressWarnings("unchecked") private Map getSubRepositories(org.eclipse.jgit.lib.Repository repo, diff --git a/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js index d8eb4ae0e0..c6aed483e7 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js +++ b/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js @@ -2,15 +2,17 @@ import React from "react"; import { repositories } from "@scm-manager/ui-components"; import type { Repository } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; type Props = { - repository: Repository + repository: Repository, + t: string => string } class ProtocolInformation extends React.Component { render() { - const { repository } = this.props; + const { repository, t } = this.props; const href = repositories.getProtocolLinkByType(repository, "http"); if (!href) { return null; @@ -18,11 +20,11 @@ class ProtocolInformation extends React.Component { return (

-

Clone the repository

+

{t("scm-git-plugin.information.clone")}

           git clone {href}
         
-

Create a new repository

+

{t("scm-git-plugin.information.create")}

           
             git init {repository.name}
@@ -39,7 +41,7 @@ class ProtocolInformation extends React.Component {
             
-

Push an existing repository

+

{t("scm-git-plugin.information.replace")}

           
             git remote add origin {href}
@@ -54,4 +56,4 @@ class ProtocolInformation extends React.Component {
 
 }
 
-export default ProtocolInformation;
+export default translate("plugins")(ProtocolInformation);
diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json
new file mode 100644
index 0000000000..1dc0e254c2
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json
@@ -0,0 +1,9 @@
+{
+  "scm-git-plugin": {
+    "information": {
+      "clone" : "Repository Klonen",
+      "create" : "Neue Repository erstellen",
+      "replace" : "Eine existierende Repository aktualisieren"
+    }
+  }
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json
new file mode 100644
index 0000000000..65594bae19
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json
@@ -0,0 +1,9 @@
+{
+  "scm-git-plugin": {
+    "information": {
+      "clone" : "Clone the repository",
+      "create" : "Create a new repository",
+      "replace" : "Push an existing repository"
+    }
+  }
+}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java
index a6e704501e..22116eff01 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java
@@ -6,13 +6,13 @@
  * modification, are permitted provided that the following conditions are met:
  *
  * 1. Redistributions of source code must retain the above copyright notice,
- *    this list of conditions and the following disclaimer.
+ * this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
- *    this list of conditions and the following disclaimer in the documentation
- *    and/or other materials provided with the distribution.
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
  * 3. Neither the name of SCM-Manager; nor the names of its
- *    contributors may be used to endorse or promote products derived from this
- *    software without specific prior written permission.
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
  *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
@@ -26,107 +26,87 @@
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  *
  * http://bitbucket.org/sdorra/scm-manager
- *
  */
 
 
-
 package sonia.scm.repository.spi;
 
-//~--- non-JDK imports --------------------------------------------------------
-
 import org.junit.Test;
+import sonia.scm.NotFoundException;
 import sonia.scm.repository.BrowserResult;
 import sonia.scm.repository.FileObject;
 import sonia.scm.repository.GitConstants;
 
 import java.io.IOException;
-import java.util.List;
+import java.util.Collection;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
-//~--- JDK imports ------------------------------------------------------------
-
 /**
  * Unit tests for {@link GitBrowseCommand}.
- * 
+ *
  * @author Sebastian Sdorra
  */
-public class GitBrowseCommandTest extends AbstractGitCommandTestBase
-{
-  
-  /**
-   * Test browse command with default branch.
-   */
+public class GitBrowseCommandTest extends AbstractGitCommandTestBase {
+
   @Test
   public void testDefaultBranch() throws IOException {
+    BrowseCommandRequest request = new BrowseCommandRequest();
+    request.setPath("a.txt");
+    BrowserResult result = createCommand().getBrowserResult(request);
+    FileObject fileObject = result.getFile();
+    assertEquals("a.txt", fileObject.getName());
+  }
+
+  @Test
+  public void testDefaultDefaultBranch() throws IOException, NotFoundException {
     // without default branch, the repository head should be used
-    BrowserResult result = createCommand().getBrowserResult(new BrowseCommandRequest());
-    assertNotNull(result);
+    FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
+    assertNotNull(root);
 
-    List foList = result.getFiles(); 
+    Collection foList = root.getChildren();
     assertNotNull(foList);
     assertFalse(foList.isEmpty());
-    assertEquals(4, foList.size());
-    
-    assertEquals("a.txt", foList.get(0).getName());
-    assertEquals("b.txt", foList.get(1).getName());
-    assertEquals("c", foList.get(2).getName());
-    assertEquals("f.txt", foList.get(3).getName());
-    
-    // set default branch and fetch again
+
+    assertThat(foList)
+      .extracting("name")
+      .containsExactly("a.txt", "b.txt", "c", "f.txt");
+  }
+
+  @Test
+  public void testExplicitDefaultBranch() throws IOException, NotFoundException {
     repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch");
-    result = createCommand().getBrowserResult(new BrowseCommandRequest());
-    assertNotNull(result);
 
-    foList = result.getFiles(); 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(2, foList.size());
-    
-    assertEquals("a.txt", foList.get(0).getName());
-    assertEquals("c", foList.get(1).getName());
+    FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
+    assertNotNull(root);
+
+    Collection foList = root.getChildren();
+    assertThat(foList)
+      .extracting("name")
+      .containsExactly("a.txt", "c");
   }
 
   @Test
   public void testBrowse() throws IOException {
-    BrowserResult result =
-      createCommand().getBrowserResult(new BrowseCommandRequest());
+    FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
+    assertNotNull(root);
 
-    assertNotNull(result);
+    Collection foList = root.getChildren();
 
-    List foList = result.getFiles();
+    FileObject a = findFile(foList, "a.txt");
+    FileObject c = findFile(foList, "c");
 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(4, foList.size());
-
-    FileObject a = null;
-    FileObject c = null;
-
-    for (FileObject f : foList)
-    {
-      if ("a.txt".equals(f.getName()))
-      {
-        a = f;
-      }
-      else if ("c".equals(f.getName()))
-      {
-        c = f;
-      }
-    }
-
-    assertNotNull(a);
     assertFalse(a.isDirectory());
     assertEquals("a.txt", a.getName());
     assertEquals("a.txt", a.getPath());
     assertEquals("added new line for blame", a.getDescription());
     assertTrue(a.getLength() > 0);
     checkDate(a.getLastModified());
-    assertNotNull(c);
+
     assertTrue(c.isDirectory());
     assertEquals("c", c.getName());
     assertEquals("c", c.getPath());
@@ -138,39 +118,22 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase
 
     request.setPath("c");
 
-    BrowserResult result = createCommand().getBrowserResult(request);
+    FileObject root = createCommand().getBrowserResult(request).getFile();
 
-    assertNotNull(result);
+    Collection foList = root.getChildren();
 
-    List foList = result.getFiles();
+    assertThat(foList).hasSize(2);
 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(2, foList.size());
+    FileObject d = findFile(foList, "d.txt");
+    FileObject e = findFile(foList, "e.txt");
 
-    FileObject d = null;
-    FileObject e = null;
-
-    for (FileObject f : foList)
-    {
-      if ("d.txt".equals(f.getName()))
-      {
-        d = f;
-      }
-      else if ("e.txt".equals(f.getName()))
-      {
-        e = f;
-      }
-    }
-
-    assertNotNull(d);
     assertFalse(d.isDirectory());
     assertEquals("d.txt", d.getName());
     assertEquals("c/d.txt", d.getPath());
     assertEquals("added file d and e in folder c", d.getDescription());
     assertTrue(d.getLength() > 0);
     checkDate(d.getLastModified());
-    assertNotNull(e);
+
     assertFalse(e.isDirectory());
     assertEquals("e.txt", e.getName());
     assertEquals("c/e.txt", e.getPath());
@@ -185,25 +148,30 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase
 
     request.setRecursive(true);
 
-    BrowserResult result = createCommand().getBrowserResult(request);
+    FileObject root = createCommand().getBrowserResult(request).getFile();
 
-    assertNotNull(result);
+    Collection foList = root.getChildren();
 
-    List foList = result.getFiles();
+    assertThat(foList)
+      .extracting("name")
+      .containsExactly("a.txt", "b.txt", "c", "f.txt");
 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(5, foList.size());
+    FileObject c = findFile(foList, "c");
+
+    Collection cChildren = c.getChildren();
+    assertThat(cChildren)
+      .extracting("name")
+      .containsExactly("d.txt", "e.txt");
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @return
-   */
-  private GitBrowseCommand createCommand()
-  {
+  private FileObject findFile(Collection foList, String name) {
+    return foList.stream()
+      .filter(f -> name.equals(f.getName()))
+      .findFirst()
+      .orElseThrow(() -> new AssertionError("file " + name + " not found"));
+  }
+
+  private GitBrowseCommand createCommand() {
     return new GitBrowseCommand(createContext(), repository);
   }
 }
diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java
index 4e4721ba14..19a8724b69 100644
--- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java
+++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java
@@ -35,8 +35,10 @@ package sonia.scm.repository.spi;
 
 //~--- non-JDK imports --------------------------------------------------------
 
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import sonia.scm.repository.BrowserResult;
+import sonia.scm.repository.FileObject;
 import sonia.scm.repository.Repository;
 import sonia.scm.repository.spi.javahg.HgFileviewCommand;
 
@@ -45,6 +47,7 @@ import java.io.IOException;
 //~--- JDK imports ------------------------------------------------------------
 
 /**
+ * Utilizes the mercurial fileview extension in order to support mercurial repository browsing.
  *
  * @author Sebastian Sdorra
  */
@@ -94,16 +97,7 @@ public class HgBrowseCommand extends AbstractCommand implements BrowseCommand
       cmd.disableSubRepositoryDetection();
     }
 
-    BrowserResult result = new BrowserResult();
-
-    result.setFiles(cmd.execute());
-
-    if (!Strings.isNullOrEmpty(request.getRevision())) {
-      result.setRevision(request.getRevision());
-    } else {
-      result.setRevision("tip");
-    }
-
-    return result;
+    FileObject file = cmd.execute();
+    return new BrowserResult(MoreObjects.firstNonNull(request.getRevision(), "tip"), file);
   }
 }
diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java
index 74695217d2..f351ffa572 100644
--- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java
+++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java
@@ -50,35 +50,31 @@ import sonia.scm.repository.SubRepository;
 
 import java.io.IOException;
 
+import java.util.Deque;
+import java.util.LinkedList;
 import java.util.List;
 
 /**
+ * Mercurial command to list files of a repository.
  *
  * @author Sebastian Sdorra
  */
 public class HgFileviewCommand extends AbstractCommand
 {
 
-  /**
-   * Constructs ...
-   *
-   *
-   * @param repository
-   */
-  public HgFileviewCommand(Repository repository)
+  private boolean disableLastCommit = false;
+
+  private HgFileviewCommand(Repository repository)
   {
     super(repository);
   }
 
-  //~--- methods --------------------------------------------------------------
-
   /**
-   * Method description
+   * Create command for the given repository.
    *
+   * @param repository repository
    *
-   * @param repository
-   *
-   * @return
+   * @return fileview command
    */
   public static HgFileviewCommand on(Repository repository)
   {
@@ -86,13 +82,11 @@ public class HgFileviewCommand extends AbstractCommand
   }
 
   /**
-   * Method description
+   * Disable last commit fetching for file objects.
    *
-   *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand disableLastCommit()
-  {
+  public HgFileviewCommand disableLastCommit() {
     disableLastCommit = true;
     cmdAppend("-d");
 
@@ -100,132 +94,128 @@ public class HgFileviewCommand extends AbstractCommand
   }
 
   /**
-   * Method description
+   * Disables sub repository detection
    *
-   *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand disableSubRepositoryDetection()
-  {
+  public HgFileviewCommand disableSubRepositoryDetection() {
     cmdAppend("-s");
 
     return this;
   }
 
   /**
-   * Method description
+   * Start file object fetching at the given path.
    *
    *
-   * @return
+   * @param path path to start fetching
    *
-   * @throws IOException
+   * @return {@code this}
    */
-  public List execute() throws IOException
-  {
-    cmdAppend("-t");
-
-    List files = Lists.newArrayList();
-
-    HgInputStream stream = launchStream();
-
-    while (stream.peek() != -1)
-    {
-      FileObject file = null;
-      char type = (char) stream.read();
-
-      if (type == 'd')
-      {
-        file = readDirectory(stream);
-      }
-      else if (type == 'f')
-      {
-        file = readFile(stream);
-      }
-      else if (type == 's')
-      {
-        file = readSubRepository(stream);
-      }
-
-      if (file != null)
-      {
-        files.add(file);
-      }
-    }
-
-    return files;
-  }
-
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
-  public HgFileviewCommand path(String path)
-  {
+  public HgFileviewCommand path(String path) {
     cmdAppend("-p", path);
 
     return this;
   }
 
   /**
-   * Method description
+   * Fetch file objects recursive.
    *
    *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand recursive()
-  {
+  public HgFileviewCommand recursive() {
     cmdAppend("-c");
 
     return this;
   }
 
   /**
-   * Method description
+   * Use given revision for file view.
    *
+   * @param revision revision id, hash, tag or branch
    *
-   * @param revision
-   *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand rev(String revision)
-  {
+  public HgFileviewCommand rev(String revision) {
     cmdAppend("-r", revision);
 
     return this;
   }
 
-  //~--- get methods ----------------------------------------------------------
-
   /**
-   * Method description
+   * Executes the mercurial command and parses the output.
    *
-   *
-   * @return
-   */
-  @Override
-  public String getCommandName()
-  {
-    return HgFileviewExtension.NAME;
-  }
-
-  //~--- methods --------------------------------------------------------------
-
-  /**
-   * Method description
-   *
-   *
-   * @param stream
-   *
-   * @return
+   * @return file object
    *
    * @throws IOException
    */
-  private FileObject readDirectory(HgInputStream stream) throws IOException
+  public FileObject execute() throws IOException
   {
+    cmdAppend("-t");
+
+    Deque stack = new LinkedList<>();
+
+    HgInputStream stream = launchStream();
+
+    FileObject last = null;
+    while (stream.peek() != -1) {
+      FileObject file = read(stream);
+
+      while (!stack.isEmpty()) {
+        FileObject current = stack.peek();
+        if (isParent(current, file)) {
+          current.addChild(file);
+          break;
+        } else {
+          stack.pop();
+        }
+      }
+
+      if (file.isDirectory()) {
+        stack.push(file);
+      }
+      last = file;
+    }
+
+    if (stack.isEmpty()) {
+      // if the stack is empty, the requested path is probably a file
+      return last;
+    } else {
+      // if the stack is not empty, the requested path is a directory
+      return stack.getLast();
+    }
+  }
+
+  private FileObject read(HgInputStream stream) throws IOException {
+    char type = (char) stream.read();
+
+    FileObject file;
+    switch (type) {
+      case 'd':
+        file = readDirectory(stream);
+        break;
+      case 'f':
+        file = readFile(stream);
+        break;
+      case 's':
+        file = readSubRepository(stream);
+        break;
+      default:
+        throw new IOException("unknown file object type: " + type);
+    }
+    return file;
+  }
+
+  private boolean isParent(FileObject parent, FileObject child) {
+    String parentPath = parent.getPath();
+    if (parentPath.equals("")) {
+      return true;
+    }
+    return child.getParentPath().equals(parentPath);
+  }
+
+  private FileObject readDirectory(HgInputStream stream) throws IOException {
     FileObject directory = new FileObject();
     String path = removeTrailingSlash(stream.textUpTo('\0'));
 
@@ -236,18 +226,7 @@ public class HgFileviewCommand extends AbstractCommand
     return directory;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param stream
-   *
-   * @return
-   *
-   * @throws IOException
-   */
-  private FileObject readFile(HgInputStream stream) throws IOException
-  {
+  private FileObject readFile(HgInputStream stream) throws IOException {
     FileObject file = new FileObject();
     String path = removeTrailingSlash(stream.textUpTo('\n'));
 
@@ -259,8 +238,7 @@ public class HgFileviewCommand extends AbstractCommand
     DateTime timestamp = stream.dateTimeUpTo(' ');
     String description = stream.textUpTo('\0');
 
-    if (!disableLastCommit)
-    {
+    if (!disableLastCommit) {
       file.setLastModified(timestamp.getDate().getTime());
       file.setDescription(description);
     }
@@ -268,18 +246,7 @@ public class HgFileviewCommand extends AbstractCommand
     return file;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param stream
-   *
-   * @return
-   *
-   * @throws IOException
-   */
-  private FileObject readSubRepository(HgInputStream stream) throws IOException
-  {
+  private FileObject readSubRepository(HgInputStream stream) throws IOException {
     FileObject directory = new FileObject();
     String path = removeTrailingSlash(stream.textUpTo('\n'));
 
@@ -292,8 +259,7 @@ public class HgFileviewCommand extends AbstractCommand
 
     SubRepository subRepository = new SubRepository(url);
 
-    if (!Strings.isNullOrEmpty(revision))
-    {
+    if (!Strings.isNullOrEmpty(revision)) {
       subRepository.setRevision(revision);
     }
 
@@ -302,48 +268,33 @@ public class HgFileviewCommand extends AbstractCommand
     return directory;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
-  private String removeTrailingSlash(String path)
-  {
-    if (path.endsWith("/"))
-    {
+  private String removeTrailingSlash(String path) {
+    if (path.endsWith("/")) {
       path = path.substring(0, path.length() - 1);
     }
 
     return path;
   }
 
-  //~--- get methods ----------------------------------------------------------
-
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
-  private String getNameFromPath(String path)
-  {
+  private String getNameFromPath(String path) {
     int index = path.lastIndexOf('/');
 
-    if (index > 0)
-    {
+    if (index > 0) {
       path = path.substring(index + 1);
     }
 
     return path;
   }
 
-  //~--- fields ---------------------------------------------------------------
+  /**
+   * Returns the name of the mercurial command.
+   *
+   * @return command name
+   */
+  @Override
+  public String getCommandName()
+  {
+    return HgFileviewExtension.NAME;
+  }
 
-  /** Field description */
-  private boolean disableLastCommit = false;
 }
diff --git a/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js
index 03fc41450a..a8ae91cdfb 100644
--- a/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js
+++ b/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js
@@ -2,26 +2,28 @@
 import React from "react";
 import { repositories } from "@scm-manager/ui-components";
 import type { Repository } from "@scm-manager/ui-types";
+import { translate } from "react-i18next";
 
 type Props = {
-  repository: Repository
+  repository: Repository,
+  t: string => string
 }
 
 class ProtocolInformation extends React.Component {
 
   render() {
-    const { repository } = this.props;
+    const { repository, t } = this.props;
     const href = repositories.getProtocolLinkByType(repository, "http");
     if (!href) {
       return null;
     }
     return (
       
-

Clone the repository

+

{t("scm-hg-plugin.information.clone")}

           hg clone {href}
         
-

Create a new repository

+

{t("scm-hg-plugin.information.create")}

           
             hg init {repository.name}
@@ -41,7 +43,7 @@ class ProtocolInformation extends React.Component {
             
-

Push an existing repository

+

{t("scm-hg-plugin.information.replace")}

           
             # add the repository url as default to your .hg/hgrc e.g:
@@ -59,4 +61,4 @@ class ProtocolInformation extends React.Component {
 
 }
 
-export default ProtocolInformation;
+export default translate("plugins")(ProtocolInformation);
diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json
new file mode 100644
index 0000000000..0824a4ad38
--- /dev/null
+++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json
@@ -0,0 +1,9 @@
+{
+  "scm-hg-plugin": {
+    "information": {
+      "clone" : "Repository Klonen",
+      "create" : "Neue Repository erstellen",
+      "replace" : "Eine existierende Repository aktualisieren"
+    }
+  }
+}
diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json
new file mode 100644
index 0000000000..4ec1d4e4d2
--- /dev/null
+++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json
@@ -0,0 +1,9 @@
+{
+  "scm-hg-plugin": {
+    "information": {
+      "clone" : "Clone the repository",
+      "create" : "Create a new repository",
+      "replace" : "Push an existing repository"
+    }
+  }
+}
diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py
index 518f229011..6aa9bac2f8 100644
--- a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py
+++ b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py
@@ -32,61 +32,129 @@
 
 Prints date, size and last message of files.
 """
+
+from collections import defaultdict
 from mercurial import cmdutil,util
 
 cmdtable = {}
 command = cmdutil.command(cmdtable)
 
+FILE_MARKER = ''
+
+class File_Collector:
+
+  def __init__(self, recursive = False):
+    self.recursive = recursive
+    self.structure = defaultdict(dict, ((FILE_MARKER, []),))
+
+  def collect(self, paths, path = "", dir_only = False):
+    for p in paths:
+      if p.startswith(path):
+        self.attach(self.extract_name_without_parent(path, p), self.structure, dir_only)
+
+  def attach(self, branch, trunk, dir_only = False):
+    parts = branch.split('/', 1)
+    if len(parts) == 1:  # branch is a file
+      if dir_only:
+        trunk[parts[0]] = defaultdict(dict, ((FILE_MARKER, []),))
+      else:
+        trunk[FILE_MARKER].append(parts[0])
+    else:
+      node, others = parts
+      if node not in trunk:
+        trunk[node] = defaultdict(dict, ((FILE_MARKER, []),))
+      if self.recursive:
+        self.attach(others, trunk[node], dir_only)
+
+  def extract_name_without_parent(self, parent, name_with_parent):
+    if len(parent) > 0:
+      name_without_parent = name_with_parent[len(parent):]
+      if name_without_parent.startswith("/"):
+          name_without_parent = name_without_parent[1:]
+      return name_without_parent
+    return name_with_parent
+
+class File_Object:
+  def __init__(self, directory, path):
+    self.directory = directory
+    self.path = path
+    self.children = []
+    self.sub_repository = None
+
+  def get_name(self):
+    parts = self.path.split("/")
+    return parts[len(parts) - 1]
+
+  def get_parent(self):
+    idx = self.path.rfind("/")
+    if idx > 0:
+      return self.path[0:idx]
+    return ""
+
+  def add_child(self, child):
+    self.children.append(child)
+
+  def __getitem__(self, key):
+    return self.children[key]
+
+  def __len__(self):
+    return len(self.children)
+
+  def __repr__(self):
+    result = self.path
+    if self.directory:
+      result += "/"
+    return result
+
+class File_Walker:
+  
+  def __init__(self, sub_repositories, visitor):
+    self.visitor = visitor
+    self.sub_repositories = sub_repositories
+
+  def create_file(self, path):
+    return File_Object(False, path)
+
+  def create_directory(self, path):
+    directory = File_Object(True, path)
+    if path in self.sub_repositories:
+      directory.sub_repository = self.sub_repositories[path]
+    return directory
+
+  def visit_file(self, path):
+    file = self.create_file(path)
+    self.visit(file)
+
+  def visit_directory(self, path):
+    file = self.create_directory(path)
+    self.visit(file)
+
+  def visit(self, file):
+    self.visitor.visit(file)
+
+  def create_path(self, parent, path):
+    if len(parent) > 0:
+      return parent + "/" + path
+    return path
+
+  def walk(self, structure, parent = ""):
+    for key, value in structure.iteritems():
+      if key == FILE_MARKER:
+        if value:
+          for v in value:
+            self.visit_file(self.create_path(parent, v))
+      else:
+        self.visit_directory(self.create_path(parent, key))
+        if isinstance(value, dict):
+          self.walk(value, self.create_path(parent, key))
+        else:
+          self.visit_directory(self.create_path(parent, value))
+
 class SubRepository:
   url = None
   revision = None
 
-def removeTrailingSlash(path):
-  if path.endswith('/'):
-    path = path[0:-1]
-  return path
-  
-def appendTrailingSlash(path):
-  if not path.endswith('/'):
-    path += '/'
-  return path
-
-def collectFiles(revCtx, path, files, directories, recursive):
-  length = 0
-  paths = []
-  mf = revCtx.manifest()
-  if path is "":
-    length = 1
-    for f in mf:
-      paths.append(f)
-  else:
-    length = len(path.split('/')) + 1
-    directory = path
-    if not directory.endswith('/'):
-       directory += '/'
-       
-    for f in mf:
-      if f.startswith(directory):
-        paths.append(f)
-  
-  if not recursive:
-    for p in paths:
-      parts = p.split('/')
-      depth = len(parts)
-      if depth is length:
-        file = revCtx[p]
-        files.append(file)
-      elif depth > length:
-        dirpath = ''
-        for i in range(0, length):
-          dirpath += parts[i] + '/'
-        if not dirpath in directories:
-          directories.append(dirpath)
-  else:
-    for p in paths:
-      files.append(revCtx[p])
-        
-def createSubRepositoryMap(revCtx):
+def collect_sub_repositories(revCtx):
   subrepos = {}
   try:
     hgsub = revCtx.filectx('.hgsub').data().split('\n')
@@ -98,7 +166,7 @@ def createSubRepositoryMap(revCtx):
         subrepos[parts[0].strip()] = subrepo
   except Exception:
     pass
-    
+        
   try:
     hgsubstate = revCtx.filectx('.hgsubstate').data().split('\n')
     for line in hgsubstate:
@@ -109,32 +177,77 @@ def createSubRepositoryMap(revCtx):
         subrepo.revision = subrev
   except Exception:
     pass
-    
+
   return subrepos
-  
-def printSubRepository(ui, path, subrepository, transport):
-  format = '%s %s %s\n'
-  if transport:
-    format = 's%s\n%s %s\0'
-  ui.write( format % (appendTrailingSlash(path), subrepository.revision, subrepository.url))
-  
-def printDirectory(ui, path, transport):
-  format = '%s\n'
-  if transport:
-    format = 'd%s\0'
-  ui.write( format % path)
-  
-def printFile(ui, repo, file, disableLastCommit, transport):
-  date = '0 0'
-  description = 'n/a'
-  if not disableLastCommit:
-    linkrev = repo[file.linkrev()]
-    date = '%d %d' % util.parsedate(linkrev.date())
-    description = linkrev.description()
-  format = '%s %i %s %s\n'
-  if transport:
-    format = 'f%s\n%i %s %s\0'
-  ui.write( format % (file.path(), file.size(), date, description) )
+
+class File_Printer:
+
+  def __init__(self, ui, repo, revCtx, disableLastCommit, transport):
+    self.ui = ui
+    self.repo = repo
+    self.revCtx = revCtx
+    self.disableLastCommit = disableLastCommit
+    self.transport = transport
+
+  def print_directory(self, path):
+    format = '%s/\n'
+    if self.transport:
+        format = 'd%s/\0'
+    self.ui.write( format % path)
+
+  def print_file(self, path):
+    file = self.revCtx[path]
+    date = '0 0'
+    description = 'n/a'
+    if not self.disableLastCommit:
+      linkrev = self.repo[file.linkrev()]
+      date = '%d %d' % util.parsedate(linkrev.date())
+      description = linkrev.description()
+    format = '%s %i %s %s\n'
+    if self.transport:
+      format = 'f%s\n%i %s %s\0'
+    self.ui.write( format % (file.path(), file.size(), date, description) )
+
+  def print_sub_repository(self, path, subrepo):
+    format = '%s/ %s %s\n'
+    if self.transport:
+      format = 's%s/\n%s %s\0'
+    self.ui.write( format % (path, subrepo.revision, subrepo.url))
+
+  def visit(self, file):
+    if file.sub_repository:
+      self.print_sub_repository(file.path, file.sub_repository)
+    elif file.directory:
+      self.print_directory(file.path)
+    else:
+      self.print_file(file.path)
+
+class File_Viewer:
+  def __init__(self, revCtx, visitor):
+    self.revCtx = revCtx
+    self.visitor = visitor
+    self.sub_repositories = {}
+    self.recursive = False
+
+  def remove_ending_slash(self, path):
+    if path.endswith("/"):
+        return path[:-1]
+    return path
+
+  def view(self, path = ""):
+    manifest = self.revCtx.manifest()
+    if len(path) > 0 and path in manifest:
+      self.visitor.visit(File_Object(False, path))
+    else:
+      p = self.remove_ending_slash(path)
+
+      collector = File_Collector(self.recursive)
+      walker = File_Walker(self.sub_repositories, self.visitor)
+
+      self.visitor.visit(File_Object(True, p))
+      collector.collect(manifest, p)
+      collector.collect(self.sub_repositories.keys(), p, True)
+      walker.walk(collector.structure, p)
 
 @command('fileview', [
     ('r', 'revision', 'tip', 'revision to print'),
@@ -145,23 +258,12 @@ def printFile(ui, repo, file, disableLastCommit, transport):
     ('t', 'transport', False, 'format the output for command server'),
   ])
 def fileview(ui, repo, **opts):
-  files = []
-  directories = []
-  revision = opts['revision']
-  if revision == None:
-    revision = 'tip'
-  revCtx = repo[revision]
-  path = opts['path']
-  if path.endswith('/'):
-    path = path[0:-1]
-  transport = opts['transport']
-  collectFiles(revCtx, path, files, directories, opts['recursive'])
-  if not opts['disableSubRepositoryDetection']:
-    subRepositories = createSubRepositoryMap(revCtx)
-    for k, v in subRepositories.iteritems():
-      if k.startswith(path):
-        printSubRepository(ui, k, v, transport)
-  for d in directories:
-    printDirectory(ui, d, transport)
-  for f in files:
-    printFile(ui, repo, f, opts['disableLastCommit'], transport)
+  revCtx = repo[opts["revision"]]
+  subrepos = {}
+  if not opts["disableSubRepositoryDetection"]:
+    subrepos = collect_sub_repositories(revCtx)
+  printer = File_Printer(ui, repo, revCtx, opts["disableLastCommit"], opts["transport"])
+  viewer = File_Viewer(revCtx, printer)
+  viewer.recursive = opts["recursive"]
+  viewer.sub_repositories = subrepos
+  viewer.view(opts["path"])
diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview_test.py b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview_test.py
new file mode 100644
index 0000000000..2ce3989d58
--- /dev/null
+++ b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview_test.py
@@ -0,0 +1,131 @@
+from fileview import File_Viewer, SubRepository
+import unittest
+
+class DummyRevContext():
+
+  def __init__(self, mf):
+    self.mf = mf
+
+  def manifest(self):
+    return self.mf
+
+class File_Object_Collector():
+
+  def __init__(self):
+    self.stack = []
+  
+  def __getitem__(self, key):
+    if len(self.stack) == 0 and key == 0:
+      return self.last
+    return self.stack[key]
+
+  def visit(self, file):
+    while len(self.stack) > 0:
+      current = self.stack[-1]
+      if file.get_parent() == current.path:
+        current.add_child(file)
+        break
+      else:
+        self.stack.pop()
+    if file.directory:
+      self.stack.append(file)
+    self.last = file
+    
+
+class Test_File_Viewer(unittest.TestCase):
+
+  def test_single_file(self):
+    root = self.collect(["a.txt", "b.txt"], "a.txt")
+    self.assertFile(root, "a.txt")
+
+  def test_simple(self):
+    root = self.collect(["a.txt", "b.txt"])
+    self.assertFile(root[0], "a.txt")
+    self.assertFile(root[1], "b.txt")
+
+  def test_recursive(self):
+    root = self.collect(["a", "b", "c/d.txt", "c/e.txt", "f.txt", "c/g/h.txt"], "", True)
+    self.assertChildren(root, ["a", "b", "f.txt", "c"])
+    c = root[3]
+    self.assertDirectory(c, "c")
+    self.assertChildren(c, ["c/d.txt", "c/e.txt", "c/g"])
+    g = c[2]
+    self.assertDirectory(g, "c/g")
+    self.assertChildren(g, ["c/g/h.txt"])
+
+  def test_recursive_with_path(self):
+    root = self.collect(["a", "b", "c/d.txt", "c/e.txt", "f.txt", "c/g/h.txt"], "c", True)
+    self.assertDirectory(root, "c")
+    self.assertChildren(root, ["c/d.txt", "c/e.txt", "c/g"])
+    g = root[2]
+    self.assertDirectory(g, "c/g")
+    self.assertChildren(g, ["c/g/h.txt"])
+
+  def test_recursive_with_deep_path(self):
+    root = self.collect(["a", "b", "c/d.txt", "c/e.txt", "f.txt", "c/g/h.txt"], "c/g", True)
+    self.assertDirectory(root, "c/g")
+    self.assertChildren(root, ["c/g/h.txt"])
+
+  def test_non_recursive(self):
+    root = self.collect(["a.txt", "b.txt", "c/d.txt", "c/e.txt", "c/f/g.txt"])
+    self.assertDirectory(root, "")
+    self.assertChildren(root, ["a.txt", "b.txt", "c"])
+    c = root[2]
+    self.assertEmptyDirectory(c, "c")
+
+  def test_non_recursive_with_path(self):
+    root = self.collect(["a.txt", "b.txt", "c/d.txt", "c/e.txt", "c/f/g.txt"], "c")
+    self.assertDirectory(root, "c")
+    self.assertChildren(root, ["c/d.txt", "c/e.txt", "c/f"])
+    f = root[2]
+    self.assertEmptyDirectory(f, "c/f")
+
+  def test_non_recursive_with_path_with_ending_slash(self):
+    root = self.collect(["c/d.txt"], "c/")
+    self.assertDirectory(root, "c")
+    self.assertFile(root[0], "c/d.txt")
+
+  def test_with_sub_directory(self):
+    revCtx = DummyRevContext(["a.txt", "b/c.txt"])
+    collector = File_Object_Collector()
+    viewer = File_Viewer(revCtx, collector)
+    sub_repositories = {}
+    sub_repositories["d"] = SubRepository()
+    sub_repositories["d"].url = "d"
+    sub_repositories["d"].revision = "42"
+    viewer.sub_repositories = sub_repositories
+    viewer.view()
+
+    d = collector[0][2]
+    self.assertDirectory(d, "d")
+
+
+  def collect(self, paths, path = "", recursive = False):
+    revCtx = DummyRevContext(paths)
+    collector = File_Object_Collector()
+
+    viewer = File_Viewer(revCtx, collector)
+    viewer.recursive = recursive
+    viewer.view(path)
+
+    return collector[0]
+
+  def assertChildren(self, parent, expectedPaths):
+    self.assertEqual(len(parent), len(expectedPaths))
+    for idx,item in enumerate(parent.children):
+      self.assertEqual(item.path, expectedPaths[idx])
+    
+  def assertFile(self, file, expectedPath):
+    self.assertEquals(file.path, expectedPath)
+    self.assertFalse(file.directory)
+
+  def assertDirectory(self, file, expectedPath):
+    self.assertEquals(file.path, expectedPath)
+    self.assertTrue(file.directory)
+
+  def assertEmptyDirectory(self, file, expectedPath):
+    self.assertDirectory(file, expectedPath)
+    self.assertTrue(len(file.children) == 0)
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBrowseCommandTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBrowseCommandTest.java
index c53aa8c607..32b536e69d 100644
--- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBrowseCommandTest.java
+++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBrowseCommandTest.java
@@ -33,14 +33,12 @@
 
 package sonia.scm.repository.spi;
 
-//~--- non-JDK imports --------------------------------------------------------
-
 import org.junit.Test;
 import sonia.scm.repository.BrowserResult;
 import sonia.scm.repository.FileObject;
 
 import java.io.IOException;
-import java.util.List;
+import java.util.Collection;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -48,18 +46,25 @@ import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
-//~--- JDK imports ------------------------------------------------------------
-
 /**
  *
  * @author Sebastian Sdorra
  */
-public class HgBrowseCommandTest extends AbstractHgCommandTestBase
-{
+public class HgBrowseCommandTest extends AbstractHgCommandTestBase {
+
+  @Test
+  public void testBrowseWithFilePath() throws IOException {
+    BrowseCommandRequest request = new BrowseCommandRequest();
+    request.setPath("a.txt");
+    FileObject file = new HgBrowseCommand(cmdContext, repository).getBrowserResult(request).getFile();
+    assertEquals("a.txt", file.getName());
+    assertFalse(file.isDirectory());
+    assertTrue(file.getChildren().isEmpty());
+  }
 
   @Test
   public void testBrowse() throws IOException {
-    List foList = getRootFromTip(new BrowseCommandRequest());
+    Collection foList = getRootFromTip(new BrowseCommandRequest());
     FileObject a = getFileObject(foList, "a.txt");
     FileObject c = getFileObject(foList, "c");
 
@@ -85,7 +90,9 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
 
     assertNotNull(result);
 
-    List foList = result.getFiles();
+    FileObject c = result.getFile();
+    assertEquals("c", c.getName());
+    Collection foList = c.getChildren();
 
     assertNotNull(foList);
     assertFalse(foList.isEmpty());
@@ -128,7 +135,7 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
 
     request.setDisableLastCommit(true);
 
-    List foList = getRootFromTip(request);
+    Collection foList = getRootFromTip(request);
 
     FileObject a = getFileObject(foList, "a.txt");
 
@@ -147,11 +154,16 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
 
     assertNotNull(result);
 
-    List foList = result.getFiles();
+    FileObject root = result.getFile();
+    Collection foList = root.getChildren();
 
     assertNotNull(foList);
     assertFalse(foList.isEmpty());
-    assertEquals(5, foList.size());
+    assertEquals(4, foList.size());
+
+    FileObject c = getFileObject(foList, "c");
+    assertTrue(c.isDirectory());
+    assertEquals(2, c.getChildren().size());
   }
 
   //~--- get methods ----------------------------------------------------------
@@ -165,32 +177,22 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
    *
    * @return
    */
-  private FileObject getFileObject(List foList, String name)
+  private FileObject getFileObject(Collection foList, String name)
   {
-    FileObject a = null;
-
-    for (FileObject f : foList)
-    {
-      if (name.equals(f.getName()))
-      {
-        a = f;
-
-        break;
-      }
-    }
-
-    assertNotNull(a);
-
-    return a;
+    return foList.stream()
+      .filter(f -> name.equals(f.getName()))
+      .findFirst()
+      .orElseThrow(() -> new AssertionError("file " + name + " not found"));
   }
 
-  private List getRootFromTip(BrowseCommandRequest request) throws IOException {
+  private Collection getRootFromTip(BrowseCommandRequest request) throws IOException {
     BrowserResult result = new HgBrowseCommand(cmdContext,
                              repository).getBrowserResult(request);
 
     assertNotNull(result);
 
-    List foList = result.getFiles();
+    FileObject root = result.getFile();
+    Collection foList = root.getChildren();
 
     assertNotNull(foList);
     assertFalse(foList.isEmpty());
diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java
index d5627e4d8b..e2f58b593b 100644
--- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java
+++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java
@@ -35,6 +35,7 @@ package sonia.scm.repository.spi;
 
 //~--- non-JDK imports --------------------------------------------------------
 
+import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -78,11 +79,11 @@ public class SvnBrowseCommand extends AbstractSvnCommand
   @Override
   @SuppressWarnings("unchecked")
   public BrowserResult getBrowserResult(BrowseCommandRequest request) {
-    String path = request.getPath();
+    String path = Strings.nullToEmpty(request.getPath());
     long revisionNumber = SvnUtil.getRevisionNumber(request.getRevision(), repository);
 
     if (logger.isDebugEnabled()) {
-      logger.debug("browser repository {} in path {} at revision {}", repository.getName(), path, revisionNumber);
+      logger.debug("browser repository {} in path \"{}\" at revision {}", repository.getName(), path, revisionNumber);
     }
 
     BrowserResult result = null;
@@ -90,34 +91,21 @@ public class SvnBrowseCommand extends AbstractSvnCommand
     try
     {
       SVNRepository svnRepository = open();
-      Collection entries =
-        svnRepository.getDir(Util.nonNull(path), revisionNumber, null,
-          (Collection) null);
-      List children = Lists.newArrayList();
-      String basePath = createBasePath(path);
-
-      if (request.isRecursive())
-      {
-        browseRecursive(svnRepository, revisionNumber, request, children,
-          entries, basePath);
-      }
-      else
-      {
-        for (SVNDirEntry entry : entries)
-        {
-          children.add(createFileObject(request, svnRepository, revisionNumber,
-            entry, basePath));
-
-        }
-      }
 
       if (revisionNumber == -1) {
         revisionNumber = svnRepository.getLatestRevision();
       }
 
-      result = new BrowserResult();
-      result.setRevision(String.valueOf(revisionNumber));
-      result.setFiles(children);
+      SVNDirEntry rootEntry = svnRepository.info(path, revisionNumber);
+      FileObject root = createFileObject(request, svnRepository, revisionNumber, rootEntry, path);
+      root.setPath(path);
+
+      if (root.isDirectory()) {
+        traverse(svnRepository, revisionNumber, request, root, createBasePath(path));
+      }
+
+
+      result = new BrowserResult(String.valueOf(revisionNumber), root);
     }
     catch (SVNException ex)
     {
@@ -129,52 +117,24 @@ public class SvnBrowseCommand extends AbstractSvnCommand
 
   //~--- methods --------------------------------------------------------------
 
-  /**
-   * Method description
-   *
-   *
-   * @param svnRepository
-   * @param revisionNumber
-   * @param request
-   * @param children
-   * @param entries
-   * @param basePath
-   *
-   * @throws SVNException
-   */
   @SuppressWarnings("unchecked")
-  private void browseRecursive(SVNRepository svnRepository,
-    long revisionNumber, BrowseCommandRequest request,
-    List children, Collection entries, String basePath)
+  private void traverse(SVNRepository svnRepository, long revisionNumber, BrowseCommandRequest request,
+    FileObject parent, String basePath)
     throws SVNException
   {
+    Collection entries = svnRepository.getDir(parent.getPath(), revisionNumber, null, (Collection) null);
     for (SVNDirEntry entry : entries)
     {
-      FileObject fo = createFileObject(request, svnRepository, revisionNumber,
-                        entry, basePath);
+      FileObject child = createFileObject(request, svnRepository, revisionNumber, entry, basePath);
 
-      children.add(fo);
+      parent.addChild(child);
 
-      if (fo.isDirectory())
-      {
-        Collection subEntries =
-          svnRepository.getDir(Util.nonNull(fo.getPath()), revisionNumber,
-            null, (Collection) null);
-
-        browseRecursive(svnRepository, revisionNumber, request, children,
-          subEntries, createBasePath(fo.getPath()));
+      if (child.isDirectory() && request.isRecursive()) {
+        traverse(svnRepository, revisionNumber, request, child, createBasePath(child.getPath()));
       }
     }
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
   private String createBasePath(String path)
   {
     String basePath = Util.EMPTY_STRING;
@@ -192,20 +152,6 @@ public class SvnBrowseCommand extends AbstractSvnCommand
     return basePath;
   }
 
-  /**
-   * Method description
-   *
-   *
-   *
-   *
-   * @param request
-   * @param repository
-   * @param revision
-   * @param entry
-   * @param path
-   *
-   * @return
-   */
   private FileObject createFileObject(BrowseCommandRequest request,
     SVNRepository repository, long revision, SVNDirEntry entry, String path)
   {
@@ -236,15 +182,6 @@ public class SvnBrowseCommand extends AbstractSvnCommand
     return fileObject;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param repository
-   * @param revision
-   * @param entry
-   * @param fileObject
-   */
   private void fetchExternalsProperty(SVNRepository repository, long revision,
     SVNDirEntry entry, FileObject fileObject)
   {
diff --git a/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js
index 0ba195887f..68fdc68f74 100644
--- a/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js
+++ b/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js
@@ -2,22 +2,24 @@
 import React from "react";
 import { repositories } from "@scm-manager/ui-components";
 import type { Repository } from "@scm-manager/ui-types";
+import { translate } from "react-i18next";
 
 type Props = {
-  repository: Repository
+  repository: Repository,
+  t: string => string
 }
 
 class ProtocolInformation extends React.Component {
 
   render() {
-    const { repository } = this.props;
+    const { repository, t } = this.props;
     const href = repositories.getProtocolLinkByType(repository, "http");
     if (!href) {
       return null;
     }
     return (
       
-

Checkout the repository

+

{t("scm-svn-plugin.information.checkout")}

           svn checkout {href}
         
@@ -27,4 +29,4 @@ class ProtocolInformation extends React.Component { } -export default ProtocolInformation; +export default translate("plugins")(ProtocolInformation); diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json new file mode 100644 index 0000000000..7c58498ef1 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json @@ -0,0 +1,7 @@ +{ + "scm-svn-plugin": { + "information": { + "checkout" : "Repository auschecken" + } + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json new file mode 100644 index 0000000000..07b34baf10 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json @@ -0,0 +1,7 @@ +{ + "scm-svn-plugin": { + "information": { + "checkout" : "Checkout repository" + } + } +} diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java index d76e4f3e12..d3e6a98558 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java @@ -33,14 +33,12 @@ package sonia.scm.repository.spi; -//~--- non-JDK imports -------------------------------------------------------- - import org.junit.Test; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; import java.io.IOException; -import java.util.List; +import java.util.Collection; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -48,8 +46,6 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -//~--- JDK imports ------------------------------------------------------------ - /** * * @author Sebastian Sdorra @@ -57,9 +53,19 @@ import static org.junit.Assert.assertTrue; public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase { + @Test + public void testBrowseWithFilePath() { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setPath("a.txt"); + FileObject file = createCommand().getBrowserResult(request).getFile(); + assertEquals("a.txt", file.getName()); + assertFalse(file.isDirectory()); + assertTrue(file.getChildren().isEmpty()); + } + @Test public void testBrowse() { - List foList = getRootFromTip(new BrowseCommandRequest()); + Collection foList = getRootFromTip(new BrowseCommandRequest()); FileObject a = getFileObject(foList, "a.txt"); FileObject c = getFileObject(foList, "c"); @@ -91,7 +97,7 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase assertNotNull(result); - List foList = result.getFiles(); + Collection foList = result.getFile().getChildren(); assertNotNull(foList); assertFalse(foList.isEmpty()); @@ -134,7 +140,7 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase request.setDisableLastCommit(true); - List foList = getRootFromTip(request); + Collection foList = getRootFromTip(request); FileObject a = getFileObject(foList, "a.txt"); @@ -150,15 +156,16 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase assertNotNull(result); - List foList = result.getFiles(); + Collection foList = result.getFile().getChildren(); assertNotNull(foList); assertFalse(foList.isEmpty()); - assertEquals(4, foList.size()); - - for ( FileObject fo : foList ){ - System.out.println(fo); - } + assertEquals(2, foList.size()); + + FileObject c = getFileObject(foList, "c"); + assertEquals("c", c.getName()); + assertTrue(c.isDirectory()); + assertEquals(2, c.getChildren().size()); } /** @@ -183,31 +190,20 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase * * @return */ - private FileObject getFileObject(List foList, String name) + private FileObject getFileObject(Collection foList, String name) { - FileObject a = null; - - for (FileObject f : foList) - { - if (name.equals(f.getName())) - { - a = f; - - break; - } - } - - assertNotNull(a); - - return a; + return foList.stream() + .filter(f -> name.equals(f.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("file " + name + " not found")); } - private List getRootFromTip(BrowseCommandRequest request) { + private Collection getRootFromTip(BrowseCommandRequest request) { BrowserResult result = createCommand().getBrowserResult(request); assertNotNull(result); - List foList = result.getFiles(); + Collection foList = result.getFile().getChildren(); assertNotNull(foList); assertFalse(foList.isEmpty()); diff --git a/scm-ui-components/packages/ui-components/src/Image.js b/scm-ui-components/packages/ui-components/src/Image.js index d46a32217f..5cb7fd6aa9 100644 --- a/scm-ui-components/packages/ui-components/src/Image.js +++ b/scm-ui-components/packages/ui-components/src/Image.js @@ -9,9 +9,18 @@ type Props = { }; class Image extends React.Component { + + createImageSrc = () => { + const { src } = this.props; + if (src.startsWith("http")) { + return src; + } + return withContextPath(src); + }; + render() { - const { src, alt, className } = this.props; - return {alt}; + const { alt, className } = this.props; + return {alt}; } } diff --git a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js index dc81c4a4fc..06ff997e2d 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js +++ b/scm-ui-components/packages/ui-components/src/navigation/PrimaryNavigation.js @@ -4,38 +4,58 @@ import { translate } from "react-i18next"; import PrimaryNavigationLink from "./PrimaryNavigationLink"; type Props = { - t: string => string + t: string => string, + repositoriesLink: string, + usersLink: string, + groupsLink: string, + configLink: string, + logoutLink: string }; class PrimaryNavigation extends React.Component { render() { - const { t } = this.props; + const { t, repositoriesLink, usersLink, groupsLink, configLink, logoutLink } = this.props; + + const links = [ + repositoriesLink ? ( + ): null, + usersLink ? ( + ) : null, + groupsLink ? ( + ) : null, + configLink ? ( + ) : null, + logoutLink ? ( + ) : null + ]; + return ( ); diff --git a/scm-ui-components/packages/ui-components/src/urls.js b/scm-ui-components/packages/ui-components/src/urls.js index 543519f952..dd8888d7a3 100644 --- a/scm-ui-components/packages/ui-components/src/urls.js +++ b/scm-ui-components/packages/ui-components/src/urls.js @@ -5,6 +5,21 @@ export function withContextPath(path: string) { return contextPath + path; } +export function withEndingSlash(url: string) { + if (url.endsWith("/")) { + return url; + } + return url + "/"; +} + +export function concat(base: string, ...parts: string[]) { + let url = base; + for ( let p of parts) { + url = withEndingSlash(url) + p; + } + return url; +} + export function getPageFromMatch(match: any) { let page = parseInt(match.params.page, 10); if (isNaN(page) || !page) { diff --git a/scm-ui-components/packages/ui-components/src/urls.test.js b/scm-ui-components/packages/ui-components/src/urls.test.js index 61803f213f..e1d88bfe55 100644 --- a/scm-ui-components/packages/ui-components/src/urls.test.js +++ b/scm-ui-components/packages/ui-components/src/urls.test.js @@ -1,5 +1,27 @@ // @flow -import { getPageFromMatch } from "./urls"; +import { concat, getPageFromMatch, withEndingSlash } from "./urls"; + +describe("tests for withEndingSlash", () => { + + it("should append missing slash", () => { + expect(withEndingSlash("abc")).toBe("abc/"); + }); + + it("should not append a second slash", () => { + expect(withEndingSlash("abc/")).toBe("abc/"); + }); + +}); + +describe("concat tests", () => { + + it("should concat the parts to a single url", () => { + expect(concat("a")).toBe("a"); + expect(concat("a", "b")).toBe("a/b"); + expect(concat("a", "b", "c")).toBe("a/b/c"); + }); + +}); describe("tests for getPageFromMatch", () => { function createMatch(page: string) { diff --git a/scm-ui-components/packages/ui-types/src/IndexResources.js b/scm-ui-components/packages/ui-types/src/IndexResources.js new file mode 100644 index 0000000000..277ac6d5a8 --- /dev/null +++ b/scm-ui-components/packages/ui-types/src/IndexResources.js @@ -0,0 +1,7 @@ +//@flow +import type { Links } from "./hal"; + +export type IndexResources = { + version: string, + _links: Links +}; diff --git a/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js b/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js index ceb5fe135e..d86e499378 100644 --- a/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js +++ b/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js @@ -1,14 +1,11 @@ //@flow import type { Links } from "./hal"; -export type Permission = { - name: string, - type: string, - groupPermission: boolean, - _links?: Links +export type Permission = PermissionCreateEntry & { + _links: Links }; -export type PermissionEntry = { +export type PermissionCreateEntry = { name: string, type: string, groupPermission: boolean diff --git a/scm-ui-components/packages/ui-types/src/Sources.js b/scm-ui-components/packages/ui-types/src/Sources.js new file mode 100644 index 0000000000..c8b3fafe0c --- /dev/null +++ b/scm-ui-components/packages/ui-types/src/Sources.js @@ -0,0 +1,25 @@ +// @flow + +import type { Collection, Links } from "./hal"; + +// TODO ?? check ?? links +export type SubRepository = { + repositoryUrl: string, + browserUrl: string, + revision: string +}; + +export type File = { + name: string, + path: string, + directory: boolean, + description?: string, + revision: string, + length: number, + lastModified?: string, + subRepository?: SubRepository, // TODO + _links: Links, + _embedded: { + children: File[] + } +}; diff --git a/scm-ui-components/packages/ui-types/src/index.js b/scm-ui-components/packages/ui-types/src/index.js index ccad0e8597..883272b4d4 100644 --- a/scm-ui-components/packages/ui-types/src/index.js +++ b/scm-ui-components/packages/ui-types/src/index.js @@ -17,4 +17,8 @@ export type { Tag } from "./Tags"; export type { Config } from "./Config"; -export type { Permission, PermissionEntry, PermissionCollection } from "./RepositoryPermissions"; +export type { IndexResources } from "./IndexResources"; + +export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions"; + +export type { SubRepository, File } from "./Sources"; diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json index 28c94f63e3..60ee220318 100644 --- a/scm-ui/public/locales/en/repos.json +++ b/scm-ui/public/locales/en/repos.json @@ -24,7 +24,8 @@ "navigation-label": "Navigation", "history": "Commits", "information": "Information", - "permissions": "Permissions" + "permissions": "Permissions", + "sources": "Sources" }, "create": { "title": "Create Repository", @@ -45,6 +46,14 @@ "cancel": "No" } }, + "sources": { + "file-tree": { + "name": "Name", + "length": "Length", + "lastModified": "Last modified", + "description": "Description" + } + }, "changesets": { "error-title": "Error", "error-subtitle": "Could not fetch changesets", @@ -53,7 +62,7 @@ "description": "Description", "contact": "Contact", "date": "Date", - "summary": "Changeset {{id}} committed {{time}}" + "summary": "Changeset {{id}} was committed {{time}}" }, "author": { "name": "Author", @@ -64,29 +73,34 @@ "label": "Branches" }, "permission": { - "error-title": "Error", - "error-subtitle": "Unknown permissions error", - "name": "User or Group", - "type": "Type", - "group-permission": "Group Permission", - "edit-permission": { - "delete-button": "Delete", - "save-button": "Save Changes" - }, - "delete-permission-button": { - "label": "Delete", - "confirm-alert": { - "title": "Delete permission", - "message": "Do you really want to delete the permission?", - "submit": "Yes", - "cancel": "No" + "error-title": "Error", + "error-subtitle": "Unknown permissions error", + "name": "User or Group", + "type": "Type", + "group-permission": "Group Permission", + "edit-permission": { + "delete-button": "Delete", + "save-button": "Save Changes" + }, + "delete-permission-button": { + "label": "Delete", + "confirm-alert": { + "title": "Delete permission", + "message": "Do you really want to delete the permission?", + "submit": "Yes", + "cancel": "No" + } + }, + "add-permission": { + "add-permission-heading": "Add new Permission", + "submit-button": "Submit", + "name-input-invalid": "Permission is not allowed to be empty! If it is not empty, your input name is invalid or it already exists!" + }, + "help": { + "groupPermissionHelpText": "States if a permission is a group permission.", + "nameHelpText": "Manage permissions for a specific user or group", + "typeHelpText": "READ = read; WRITE = read and write; OWNER = read, write and also the ability to manage the properties and permissions" } - }, - "add-permission": { - "add-permission-heading": "Add new Permission", - "submit-button": "Submit", - "name-input-invalid": "Permission is not allowed to be empty! If it is not empty, your input name is invalid or it already exists!" - } }, "help": { "nameHelpText": "The name of the repository. This name will be part of the repository url.", diff --git a/scm-ui/src/config/containers/GlobalConfig.js b/scm-ui/src/config/containers/GlobalConfig.js index 09bdc4b7c5..252e880a42 100644 --- a/scm-ui/src/config/containers/GlobalConfig.js +++ b/scm-ui/src/config/containers/GlobalConfig.js @@ -14,18 +14,20 @@ import { modifyConfigReset } from "../modules/config"; import { connect } from "react-redux"; -import type { Config } from "@scm-manager/ui-types"; +import type { Config, Link } from "@scm-manager/ui-types"; import ConfigForm from "../components/form/ConfigForm"; +import { getConfigLink } from "../../modules/indexResource"; type Props = { loading: boolean, error: Error, config: Config, configUpdatePermission: boolean, + configLink: string, // dispatch functions modifyConfig: (config: Config, callback?: () => void) => void, - fetchConfig: void => void, + fetchConfig: (link: string) => void, configReset: void => void, // context objects @@ -35,7 +37,7 @@ type Props = { class GlobalConfig extends React.Component { componentDidMount() { this.props.configReset(); - this.props.fetchConfig(); + this.props.fetchConfig(this.props.configLink); } modifyConfig = (config: Config) => { @@ -75,8 +77,8 @@ class GlobalConfig extends React.Component { const mapDispatchToProps = dispatch => { return { - fetchConfig: () => { - dispatch(fetchConfig()); + fetchConfig: (link: string) => { + dispatch(fetchConfig(link)); }, modifyConfig: (config: Config, callback?: () => void) => { dispatch(modifyConfig(config, callback)); @@ -92,12 +94,14 @@ const mapStateToProps = state => { const error = getFetchConfigFailure(state) || getModifyConfigFailure(state); const config = getConfig(state); const configUpdatePermission = getConfigUpdatePermission(state); + const configLink = getConfigLink(state); return { loading, error, config, - configUpdatePermission + configUpdatePermission, + configLink }; }; diff --git a/scm-ui/src/config/modules/config.js b/scm-ui/src/config/modules/config.js index 02f55b346f..352afefb70 100644 --- a/scm-ui/src/config/modules/config.js +++ b/scm-ui/src/config/modules/config.js @@ -18,15 +18,14 @@ export const MODIFY_CONFIG_SUCCESS = `${MODIFY_CONFIG}_${types.SUCCESS_SUFFIX}`; export const MODIFY_CONFIG_FAILURE = `${MODIFY_CONFIG}_${types.FAILURE_SUFFIX}`; export const MODIFY_CONFIG_RESET = `${MODIFY_CONFIG}_${types.RESET_SUFFIX}`; -const CONFIG_URL = "config"; const CONTENT_TYPE_CONFIG = "application/vnd.scmm-config+json;v=2"; //fetch config -export function fetchConfig() { +export function fetchConfig(link: string) { return function(dispatch: any) { dispatch(fetchConfigPending()); return apiClient - .get(CONFIG_URL) + .get(link) .then(response => { return response.json(); }) diff --git a/scm-ui/src/config/modules/config.test.js b/scm-ui/src/config/modules/config.test.js index baff061a30..12c6b347c3 100644 --- a/scm-ui/src/config/modules/config.test.js +++ b/scm-ui/src/config/modules/config.test.js @@ -22,8 +22,10 @@ import reducer, { getConfig, getConfigUpdatePermission } from "./config"; +import { getConfigLink } from "../../modules/indexResource"; -const CONFIG_URL = "/api/v2/config"; +const CONFIG_URL = "/config"; +const URL = "/api/v2" + CONFIG_URL; const error = new Error("You have an error!"); @@ -103,7 +105,7 @@ describe("config fetch()", () => { }); it("should successfully fetch config", () => { - fetchMock.getOnce(CONFIG_URL, response); + fetchMock.getOnce(URL, response); const expectedActions = [ { type: FETCH_CONFIG_PENDING }, @@ -113,20 +115,36 @@ describe("config fetch()", () => { } ]; - const store = mockStore({}); + const store = mockStore({ + indexResources: { + links: { + config: { + href: CONFIG_URL + } + } + } + }); - return store.dispatch(fetchConfig()).then(() => { + return store.dispatch(fetchConfig(CONFIG_URL)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); it("should fail getting config on HTTP 500", () => { - fetchMock.getOnce(CONFIG_URL, { + fetchMock.getOnce(URL, { status: 500 }); - const store = mockStore({}); - return store.dispatch(fetchConfig()).then(() => { + const store = mockStore({ + indexResources: { + links: { + config: { + href: CONFIG_URL + } + } + } + }); + return store.dispatch(fetchConfig(CONFIG_URL)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_CONFIG_PENDING); expect(actions[1].type).toEqual(FETCH_CONFIG_FAILURE); diff --git a/scm-ui/src/containers/App.js b/scm-ui/src/containers/App.js index 8042611d01..768b1776d4 100644 --- a/scm-ui/src/containers/App.js +++ b/scm-ui/src/containers/App.js @@ -19,16 +19,33 @@ import { Footer, Header } from "@scm-manager/ui-components"; -import type { Me } from "@scm-manager/ui-types"; +import type { Me, Link } from "@scm-manager/ui-types"; +import { + fetchIndexResources, + getConfigLink, + getFetchIndexResourcesFailure, + getGroupsLink, + getLogoutLink, + getMeLink, + getRepositoriesLink, + getUsersLink, + isFetchIndexResourcesPending +} from "../modules/indexResource"; type Props = { me: Me, authenticated: boolean, error: Error, loading: boolean, + repositoriesLink: string, + usersLink: string, + groupsLink: string, + configLink: string, + logoutLink: string, + meLink: string, // dispatcher functions - fetchMe: () => void, + fetchMe: (link: string) => void, // context props t: string => string @@ -36,14 +53,37 @@ type Props = { class App extends Component { componentDidMount() { - this.props.fetchMe(); + if (this.props.meLink) { + this.props.fetchMe(this.props.meLink); + } } render() { - const { me, loading, error, authenticated, t } = this.props; + const { + me, + loading, + error, + authenticated, + t, + repositoriesLink, + usersLink, + groupsLink, + configLink, + logoutLink + } = this.props; let content; - const navigation = authenticated ? : ""; + const navigation = authenticated ? ( + + ) : ( + "" + ); if (loading) { content = ; @@ -70,20 +110,34 @@ class App extends Component { const mapDispatchToProps = (dispatch: any) => { return { - fetchMe: () => dispatch(fetchMe()) + fetchMe: (link: string) => dispatch(fetchMe(link)) }; }; const mapStateToProps = state => { const authenticated = isAuthenticated(state); const me = getMe(state); - const loading = isFetchMePending(state); - const error = getFetchMeFailure(state); + const loading = + isFetchMePending(state) || isFetchIndexResourcesPending(state); + const error = + getFetchMeFailure(state) || getFetchIndexResourcesFailure(state); + const repositoriesLink = getRepositoriesLink(state); + const usersLink = getUsersLink(state); + const groupsLink = getGroupsLink(state); + const configLink = getConfigLink(state); + const logoutLink = getLogoutLink(state); + const meLink = getMeLink(state); return { authenticated, me, loading, - error + error, + repositoriesLink, + usersLink, + groupsLink, + configLink, + logoutLink, + meLink }; }; diff --git a/scm-ui/src/containers/Index.js b/scm-ui/src/containers/Index.js new file mode 100644 index 0000000000..0fe6364f6e --- /dev/null +++ b/scm-ui/src/containers/Index.js @@ -0,0 +1,80 @@ +// @flow +import React, { Component } from "react"; +import App from "./App"; +import { connect } from "react-redux"; +import { translate } from "react-i18next"; +import { withRouter } from "react-router-dom"; + +import { Loading, ErrorPage } from "@scm-manager/ui-components"; +import { + fetchIndexResources, + getFetchIndexResourcesFailure, + getLinks, + isFetchIndexResourcesPending +} from "../modules/indexResource"; +import PluginLoader from "./PluginLoader"; +import type { IndexResources } from "@scm-manager/ui-types"; + +type Props = { + error: Error, + loading: boolean, + indexResources: IndexResources, + + // dispatcher functions + fetchIndexResources: () => void, + + // context props + t: string => string +}; + +class Index extends Component { + componentDidMount() { + this.props.fetchIndexResources(); + } + + render() { + const { indexResources, loading, error, t } = this.props; + + if (error) { + return ( + + ); + } else if (loading || !indexResources) { + return ; + } else { + return ( + + + + ); + } + } +} + +const mapDispatchToProps = (dispatch: any) => { + return { + fetchIndexResources: () => dispatch(fetchIndexResources()) + }; +}; + +const mapStateToProps = state => { + const loading = isFetchIndexResourcesPending(state); + const error = getFetchIndexResourcesFailure(state); + const indexResources = getLinks(state); + return { + loading, + error, + indexResources + }; +}; + +export default withRouter( + connect( + mapStateToProps, + mapDispatchToProps + )(translate("commons")(Index)) +); diff --git a/scm-ui/src/containers/Login.js b/scm-ui/src/containers/Login.js index 00e505e16d..8a06478045 100644 --- a/scm-ui/src/containers/Login.js +++ b/scm-ui/src/containers/Login.js @@ -18,6 +18,7 @@ import { Image } from "@scm-manager/ui-components"; import classNames from "classnames"; +import { getLoginLink } from "../modules/indexResource"; const styles = { avatar: { @@ -41,9 +42,10 @@ type Props = { authenticated: boolean, loading: boolean, error: Error, + link: string, // dispatcher props - login: (username: string, password: string) => void, + login: (link: string, username: string, password: string) => void, // context props t: string => string, @@ -74,7 +76,11 @@ class Login extends React.Component { handleSubmit = (event: Event) => { event.preventDefault(); if (this.isValid()) { - this.props.login(this.state.username, this.state.password); + this.props.login( + this.props.link, + this.state.username, + this.state.password + ); } }; @@ -145,17 +151,19 @@ const mapStateToProps = state => { const authenticated = isAuthenticated(state); const loading = isLoginPending(state); const error = getLoginFailure(state); + const link = getLoginLink(state); return { authenticated, loading, - error + error, + link }; }; const mapDispatchToProps = dispatch => { return { - login: (username: string, password: string) => - dispatch(login(username, password)) + login: (loginLink: string, username: string, password: string) => + dispatch(login(loginLink, username, password)) }; }; diff --git a/scm-ui/src/containers/Logout.js b/scm-ui/src/containers/Logout.js index 8d522a18bf..7875a6b92a 100644 --- a/scm-ui/src/containers/Logout.js +++ b/scm-ui/src/containers/Logout.js @@ -11,14 +11,16 @@ import { getLogoutFailure } from "../modules/auth"; import { Loading, ErrorPage } from "@scm-manager/ui-components"; +import { fetchIndexResources, getLogoutLink } from "../modules/indexResource"; type Props = { authenticated: boolean, loading: boolean, error: Error, + logoutLink: string, // dispatcher functions - logout: () => void, + logout: (link: string) => void, // context props t: string => string @@ -26,7 +28,7 @@ type Props = { class Logout extends React.Component { componentDidMount() { - this.props.logout(); + this.props.logout(this.props.logoutLink); } render() { @@ -51,16 +53,18 @@ const mapStateToProps = state => { const authenticated = isAuthenticated(state); const loading = isLogoutPending(state); const error = getLogoutFailure(state); + const logoutLink = getLogoutLink(state); return { authenticated, loading, - error + error, + logoutLink }; }; const mapDispatchToProps = dispatch => { return { - logout: () => dispatch(logout()) + logout: (link: string) => dispatch(logout(link)) }; }; diff --git a/scm-ui/src/containers/PluginLoader.js b/scm-ui/src/containers/PluginLoader.js index 16a5dd8d4d..8e44a1d427 100644 --- a/scm-ui/src/containers/PluginLoader.js +++ b/scm-ui/src/containers/PluginLoader.js @@ -1,9 +1,12 @@ // @flow import * as React from "react"; import { apiClient, Loading } from "@scm-manager/ui-components"; +import { getUiPluginsLink } from "../modules/indexResource"; +import { connect } from "react-redux"; type Props = { - children: React.Node + children: React.Node, + link: string }; type State = { @@ -29,8 +32,13 @@ class PluginLoader extends React.Component { this.setState({ message: "loading plugin information" }); - apiClient - .get("ui/plugins") + + this.getPlugins(this.props.link); + } + + getPlugins = (link: string): Promise => { + return apiClient + .get(link) .then(response => response.text()) .then(JSON.parse) .then(pluginCollection => pluginCollection._embedded.plugins) @@ -40,7 +48,7 @@ class PluginLoader extends React.Component { finished: true }); }); - } + }; loadPlugins = (plugins: Plugin[]) => { this.setState({ @@ -87,4 +95,11 @@ class PluginLoader extends React.Component { } } -export default PluginLoader; +const mapStateToProps = state => { + const link = getUiPluginsLink(state); + return { + link + }; +}; + +export default connect(mapStateToProps)(PluginLoader); diff --git a/scm-ui/src/createReduxStore.js b/scm-ui/src/createReduxStore.js index 710ade20e5..bc519f3741 100644 --- a/scm-ui/src/createReduxStore.js +++ b/scm-ui/src/createReduxStore.js @@ -8,12 +8,14 @@ import users from "./users/modules/users"; import repos from "./repos/modules/repos"; import repositoryTypes from "./repos/modules/repositoryTypes"; import changesets from "./repos/modules/changesets"; +import sources from "./repos/sources/modules/sources"; import groups from "./groups/modules/groups"; import auth from "./modules/auth"; import pending from "./modules/pending"; import failure from "./modules/failure"; import permissions from "./repos/permissions/modules/permissions"; import config from "./config/modules/config"; +import indexResources from "./modules/indexResource"; import type { BrowserHistory } from "history/createBrowserHistory"; import branches from "./repos/modules/branches"; @@ -26,6 +28,7 @@ function createReduxStore(history: BrowserHistory) { router: routerReducer, pending, failure, + indexResources, users, repos, repositoryTypes, @@ -34,7 +37,8 @@ function createReduxStore(history: BrowserHistory) { permissions, groups, auth, - config + config, + sources }); return createStore( diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js index 0d3fc8ee2f..bcb19846b8 100644 --- a/scm-ui/src/groups/containers/AddGroup.js +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -9,18 +9,21 @@ import { createGroup, isCreateGroupPending, getCreateGroupFailure, - createGroupReset + createGroupReset, + getCreateGroupLink } from "../modules/groups"; import type { Group } from "@scm-manager/ui-types"; import type { History } from "history"; +import { getGroupsLink } from "../../modules/indexResource"; type Props = { t: string => string, - createGroup: (group: Group, callback?: () => void) => void, + createGroup: (link: string, group: Group, callback?: () => void) => void, history: History, loading?: boolean, error?: Error, - resetForm: () => void + resetForm: () => void, + createLink: string }; type State = {}; @@ -51,14 +54,14 @@ class AddGroup extends React.Component { this.props.history.push("/groups"); }; createGroup = (group: Group) => { - this.props.createGroup(group, this.groupCreated); + this.props.createGroup(this.props.createLink, group, this.groupCreated); }; } const mapDispatchToProps = dispatch => { return { - createGroup: (group: Group, callback?: () => void) => - dispatch(createGroup(group, callback)), + createGroup: (link: string, group: Group, callback?: () => void) => + dispatch(createGroup(link, group, callback)), resetForm: () => { dispatch(createGroupReset()); } @@ -68,7 +71,9 @@ const mapDispatchToProps = dispatch => { const mapStateToProps = state => { const loading = isCreateGroupPending(state); const error = getCreateGroupFailure(state); + const createLink = getGroupsLink(state); return { + createLink, loading, error }; diff --git a/scm-ui/src/groups/containers/Groups.js b/scm-ui/src/groups/containers/Groups.js index 5fa1423f55..984055c60f 100644 --- a/scm-ui/src/groups/containers/Groups.js +++ b/scm-ui/src/groups/containers/Groups.js @@ -18,6 +18,7 @@ import { isPermittedToCreateGroups, selectListAsCollection } from "../modules/groups"; +import { getGroupsLink } from "../../modules/indexResource"; type Props = { groups: Group[], @@ -26,19 +27,20 @@ type Props = { canAddGroups: boolean, list: PagedCollection, page: number, + groupLink: string, // context objects t: string => string, history: History, // dispatch functions - fetchGroupsByPage: (page: number) => void, + fetchGroupsByPage: (link: string, page: number) => void, fetchGroupsByLink: (link: string) => void }; class Groups extends React.Component { componentDidMount() { - this.props.fetchGroupsByPage(this.props.page); + this.props.fetchGroupsByPage(this.props.groupLink, this.props.page); } onPageChange = (link: string) => { @@ -111,20 +113,23 @@ const mapStateToProps = (state, ownProps) => { const canAddGroups = isPermittedToCreateGroups(state); const list = selectListAsCollection(state); + const groupLink = getGroupsLink(state); + return { groups, loading, error, canAddGroups, list, - page + page, + groupLink }; }; const mapDispatchToProps = dispatch => { return { - fetchGroupsByPage: (page: number) => { - dispatch(fetchGroupsByPage(page)); + fetchGroupsByPage: (link: string, page: number) => { + dispatch(fetchGroupsByPage(link, page)); }, fetchGroupsByLink: (link: string) => { dispatch(fetchGroupsByLink(link)); diff --git a/scm-ui/src/groups/containers/SingleGroup.js b/scm-ui/src/groups/containers/SingleGroup.js index 9e0bcf74ef..d681859808 100644 --- a/scm-ui/src/groups/containers/SingleGroup.js +++ b/scm-ui/src/groups/containers/SingleGroup.js @@ -26,16 +26,18 @@ import { import { translate } from "react-i18next"; import EditGroup from "./EditGroup"; +import { getGroupsLink } from "../../modules/indexResource"; type Props = { name: string, group: Group, loading: boolean, error: Error, + groupLink: string, // dispatcher functions deleteGroup: (group: Group, callback?: () => void) => void, - fetchGroup: string => void, + fetchGroup: (string, string) => void, // context objects t: string => string, @@ -45,7 +47,7 @@ type Props = { class SingleGroup extends React.Component { componentDidMount() { - this.props.fetchGroup(this.props.name); + this.props.fetchGroup(this.props.groupLink, this.props.name); } stripEndingSlash = (url: string) => { @@ -132,19 +134,21 @@ const mapStateToProps = (state, ownProps) => { isFetchGroupPending(state, name) || isDeleteGroupPending(state, name); const error = getFetchGroupFailure(state, name) || getDeleteGroupFailure(state, name); + const groupLink = getGroupsLink(state); return { name, group, loading, - error + error, + groupLink }; }; const mapDispatchToProps = dispatch => { return { - fetchGroup: (name: string) => { - dispatch(fetchGroup(name)); + fetchGroup: (link: string, name: string) => { + dispatch(fetchGroup(link, name)); }, deleteGroup: (group: Group, callback?: () => void) => { dispatch(deleteGroup(group, callback)); diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js index 5ba9587260..74e7214052 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -32,17 +32,16 @@ export const DELETE_GROUP_PENDING = `${DELETE_GROUP}_${types.PENDING_SUFFIX}`; export const DELETE_GROUP_SUCCESS = `${DELETE_GROUP}_${types.SUCCESS_SUFFIX}`; export const DELETE_GROUP_FAILURE = `${DELETE_GROUP}_${types.FAILURE_SUFFIX}`; -const GROUPS_URL = "groups"; const CONTENT_TYPE_GROUP = "application/vnd.scmm-group+json;v=2"; // fetch groups -export function fetchGroups() { - return fetchGroupsByLink(GROUPS_URL); +export function fetchGroups(link: string) { + return fetchGroupsByLink(link); } -export function fetchGroupsByPage(page: number) { +export function fetchGroupsByPage(link: string, page: number) { // backend start counting by 0 - return fetchGroupsByLink(GROUPS_URL + "?page=" + (page - 1)); + return fetchGroupsByLink(link + "?page=" + (page - 1)); } export function fetchGroupsByLink(link: string) { @@ -56,7 +55,7 @@ export function fetchGroupsByLink(link: string) { }) .catch(cause => { const error = new Error(`could not fetch groups: ${cause.message}`); - dispatch(fetchGroupsFailure(GROUPS_URL, error)); + dispatch(fetchGroupsFailure(link, error)); }); }; } @@ -85,8 +84,8 @@ export function fetchGroupsFailure(url: string, error: Error): Action { } //fetch group -export function fetchGroup(name: string) { - const groupUrl = GROUPS_URL + "/" + name; +export function fetchGroup(link: string, name: string) { + const groupUrl = link.endsWith("/") ? link + name : link + "/" + name; return function(dispatch: any) { dispatch(fetchGroupPending(name)); return apiClient @@ -132,11 +131,11 @@ export function fetchGroupFailure(name: string, error: Error): Action { } //create group -export function createGroup(group: Group, callback?: () => void) { +export function createGroup(link: string, group: Group, callback?: () => void) { return function(dispatch: Dispatch) { dispatch(createGroupPending()); return apiClient - .post(GROUPS_URL, group, CONTENT_TYPE_GROUP) + .post(link, group, CONTENT_TYPE_GROUP) .then(() => { dispatch(createGroupSuccess()); if (callback) { @@ -410,6 +409,12 @@ export const isPermittedToCreateGroups = (state: Object): boolean => { return false; }; +export function getCreateGroupLink(state: Object) { + if (state.groups.list.entry && state.groups.list.entry._links) + return state.groups.list.entry._links.create.href; + return undefined; +} + export function getGroupsFromState(state: Object) { const groupNames = selectList(state).entries; if (!groupNames) { diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index 191a2122e4..63ab375cd3 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -42,9 +42,11 @@ import reducer, { modifyGroup, MODIFY_GROUP_PENDING, MODIFY_GROUP_SUCCESS, - MODIFY_GROUP_FAILURE + MODIFY_GROUP_FAILURE, + getCreateGroupLink } from "./groups"; const GROUPS_URL = "/api/v2/groups"; +const URL = "/groups"; const error = new Error("You have an error!"); @@ -63,7 +65,7 @@ const humanGroup = { href: "http://localhost:8081/api/v2/groups/humanGroup" }, update: { - href:"http://localhost:8081/api/v2/groups/humanGroup" + href: "http://localhost:8081/api/v2/groups/humanGroup" } }, _embedded: { @@ -95,7 +97,7 @@ const emptyGroup = { href: "http://localhost:8081/api/v2/groups/emptyGroup" }, update: { - href:"http://localhost:8081/api/v2/groups/emptyGroup" + href: "http://localhost:8081/api/v2/groups/emptyGroup" } }, _embedded: { @@ -150,7 +152,7 @@ describe("groups fetch()", () => { const store = mockStore({}); - return store.dispatch(fetchGroups()).then(() => { + return store.dispatch(fetchGroups(URL)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -161,7 +163,7 @@ describe("groups fetch()", () => { }); const store = mockStore({}); - return store.dispatch(fetchGroups()).then(() => { + return store.dispatch(fetchGroups(URL)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_GROUPS_PENDING); expect(actions[1].type).toEqual(FETCH_GROUPS_FAILURE); @@ -173,7 +175,7 @@ describe("groups fetch()", () => { fetchMock.getOnce(GROUPS_URL + "/humanGroup", humanGroup); const store = mockStore({}); - return store.dispatch(fetchGroup("humanGroup")).then(() => { + return store.dispatch(fetchGroup(URL, "humanGroup")).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_GROUP_PENDING); expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS); @@ -187,7 +189,7 @@ describe("groups fetch()", () => { }); const store = mockStore({}); - return store.dispatch(fetchGroup("humanGroup")).then(() => { + return store.dispatch(fetchGroup(URL, "humanGroup")).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_GROUP_PENDING); expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE); @@ -195,14 +197,13 @@ describe("groups fetch()", () => { }); }); - it("should successfully create group", () => { fetchMock.postOnce(GROUPS_URL, { status: 201 }); const store = mockStore({}); - return store.dispatch(createGroup(humanGroup)).then(() => { + return store.dispatch(createGroup(URL, humanGroup)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS); @@ -219,14 +220,13 @@ describe("groups fetch()", () => { called = true; }; const store = mockStore({}); - return store.dispatch(createGroup(humanGroup, callMe)).then(() => { + return store.dispatch(createGroup(URL, humanGroup, callMe)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS); expect(called).toEqual(true); }); }); - it("should fail creating group on HTTP 500", () => { fetchMock.postOnce(GROUPS_URL, { @@ -234,7 +234,7 @@ describe("groups fetch()", () => { }); const store = mockStore({}); - return store.dispatch(createGroup(humanGroup)).then(() => { + return store.dispatch(createGroup(URL, humanGroup)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[1].type).toEqual(CREATE_GROUP_FAILURE); @@ -248,7 +248,7 @@ describe("groups fetch()", () => { status: 204 }); - const store = mockStore({}); + const store = mockStore({}); return store.dispatch(modifyGroup(humanGroup)).then(() => { const actions = store.getActions(); @@ -267,7 +267,7 @@ describe("groups fetch()", () => { const callback = () => { called = true; }; - const store = mockStore({}); + const store = mockStore({}); return store.dispatch(modifyGroup(humanGroup, callback)).then(() => { const actions = store.getActions(); @@ -282,7 +282,7 @@ describe("groups fetch()", () => { status: 500 }); - const store = mockStore({}); + const store = mockStore({}); return store.dispatch(modifyGroup(humanGroup)).then(() => { const actions = store.getActions(); @@ -337,13 +337,10 @@ describe("groups fetch()", () => { expect(actions[1].payload).toBeDefined(); }); }); - }); describe("groups reducer", () => { - it("should update state correctly according to FETCH_GROUPS_SUCCESS action", () => { - const newState = reducer({}, fetchGroupsSuccess(responseBody)); expect(newState.list).toEqual({ @@ -391,7 +388,6 @@ describe("groups reducer", () => { expect(newState.byNames["humanGroup"]).toBeTruthy(); }); - it("should update state according to FETCH_GROUP_SUCCESS action", () => { const newState = reducer({}, fetchGroupSuccess(emptyGroup)); expect(newState.byNames["emptyGroup"]).toBe(emptyGroup); @@ -426,7 +422,6 @@ describe("groups reducer", () => { expect(newState.byNames["emptyGroup"]).toBeFalsy(); expect(newState.list.entries).toEqual(["humanGroup"]); }); - }); describe("selector tests", () => { @@ -476,6 +471,23 @@ describe("selector tests", () => { expect(isPermittedToCreateGroups(state)).toBe(true); }); + it("should return create Group link", () => { + const state = { + groups: { + list: { + entry: { + _links: { + create: { + href: "/create" + } + } + } + } + } + }; + expect(getCreateGroupLink(state)).toBe("/create"); + }); + it("should get groups from state", () => { const state = { groups: { @@ -488,7 +500,7 @@ describe("selector tests", () => { } } }; - + expect(getGroupsFromState(state)).toEqual([{ name: "a" }, { name: "b" }]); }); @@ -560,9 +572,13 @@ describe("selector tests", () => { }); it("should return true if create group is pending", () => { - expect(isCreateGroupPending({pending: { - [CREATE_GROUP]: true - }})).toBeTruthy(); + expect( + isCreateGroupPending({ + pending: { + [CREATE_GROUP]: true + } + }) + ).toBeTruthy(); }); it("should return false if create group is not pending", () => { @@ -570,18 +586,19 @@ describe("selector tests", () => { }); it("should return error if creating group failed", () => { - expect(getCreateGroupFailure({ - failure: { - [CREATE_GROUP]: error - } - })).toEqual(error); + expect( + getCreateGroupFailure({ + failure: { + [CREATE_GROUP]: error + } + }) + ).toEqual(error); }); it("should return undefined if creating group did not fail", () => { expect(getCreateGroupFailure({})).toBeUndefined(); }); - it("should return true, when delete group humanGroup is pending", () => { const state = { pending: { diff --git a/scm-ui/src/index.js b/scm-ui/src/index.js index 511252620a..3ecd38e6d0 100644 --- a/scm-ui/src/index.js +++ b/scm-ui/src/index.js @@ -1,7 +1,7 @@ // @flow import React from "react"; import ReactDOM from "react-dom"; -import App from "./containers/App"; +import Index from "./containers/Index"; import registerServiceWorker from "./registerServiceWorker"; import { I18nextProvider } from "react-i18next"; @@ -37,9 +37,7 @@ ReactDOM.render( {/* ConnectedRouter will use the store from Provider automatically */} - - - + , diff --git a/scm-ui/src/modules/auth.js b/scm-ui/src/modules/auth.js index 35dde975cc..fd5068aeb8 100644 --- a/scm-ui/src/modules/auth.js +++ b/scm-ui/src/modules/auth.js @@ -5,6 +5,12 @@ import * as types from "./types"; import { apiClient, UNAUTHORIZED_ERROR } from "@scm-manager/ui-components"; import { isPending } from "./pending"; import { getFailure } from "./failure"; +import { + callFetchIndexResources, + FETCH_INDEXRESOURCES_SUCCESS, + fetchIndexResources, fetchIndexResourcesPending, + fetchIndexResourcesSuccess +} from "./indexResource"; // Action @@ -121,16 +127,11 @@ export const fetchMeFailure = (error: Error) => { }; }; -// urls - -const ME_URL = "/me"; -const LOGIN_URL = "/auth/access_token"; - // side effects -const callFetchMe = (): Promise => { +const callFetchMe = (link: string): Promise => { return apiClient - .get(ME_URL) + .get(link) .then(response => { return response.json(); }) @@ -139,7 +140,11 @@ const callFetchMe = (): Promise => { }); }; -export const login = (username: string, password: string) => { +export const login = ( + loginLink: string, + username: string, + password: string +) => { const login_data = { cookie: true, grant_type: "password", @@ -149,9 +154,15 @@ export const login = (username: string, password: string) => { return function(dispatch: any) { dispatch(loginPending()); return apiClient - .post(LOGIN_URL, login_data) + .post(loginLink, login_data) .then(response => { - return callFetchMe(); + dispatch(fetchIndexResourcesPending()) + return callFetchIndexResources(); + }) + .then(response => { + dispatch(fetchIndexResourcesSuccess(response)); + const meLink = response._links.me.href; + return callFetchMe(meLink); }) .then(me => { dispatch(loginSuccess(me)); @@ -162,10 +173,10 @@ export const login = (username: string, password: string) => { }; }; -export const fetchMe = () => { +export const fetchMe = (link: string) => { return function(dispatch: any) { dispatch(fetchMePending()); - return callFetchMe() + return callFetchMe(link) .then(me => { dispatch(fetchMeSuccess(me)); }) @@ -179,14 +190,17 @@ export const fetchMe = () => { }; }; -export const logout = () => { +export const logout = (link: string) => { return function(dispatch: any) { dispatch(logoutPending()); return apiClient - .delete(LOGIN_URL) + .delete(link) .then(() => { dispatch(logoutSuccess()); }) + .then(() => { + dispatch(fetchIndexResources()); + }) .catch(error => { dispatch(logoutFailure(error)); }); diff --git a/scm-ui/src/modules/auth.test.js b/scm-ui/src/modules/auth.test.js index 3cea758566..1839701e0a 100644 --- a/scm-ui/src/modules/auth.test.js +++ b/scm-ui/src/modules/auth.test.js @@ -32,6 +32,10 @@ import reducer, { import configureMockStore from "redux-mock-store"; import thunk from "redux-thunk"; import fetchMock from "fetch-mock"; +import { + FETCH_INDEXRESOURCES_PENDING, + FETCH_INDEXRESOURCES_SUCCESS +} from "./indexResource"; const me = { name: "tricia", displayName: "Tricia McMillian" }; @@ -93,16 +97,30 @@ describe("auth actions", () => { headers: { "content-type": "application/json" } }); + const meLink = { + me: { + href: "/me" + } + }; + + fetchMock.getOnce("/api/v2/", { + _links: meLink + }); + const expectedActions = [ { type: LOGIN_PENDING }, + { type: FETCH_INDEXRESOURCES_PENDING }, + { type: FETCH_INDEXRESOURCES_SUCCESS, payload: { _links: meLink } }, { type: LOGIN_SUCCESS, payload: me } ]; const store = mockStore({}); - return store.dispatch(login("tricia", "secret123")).then(() => { - expect(store.getActions()).toEqual(expectedActions); - }); + return store + .dispatch(login("/auth/access_token", "tricia", "secret123")) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); }); it("should dispatch login failure", () => { @@ -111,12 +129,14 @@ describe("auth actions", () => { }); const store = mockStore({}); - return store.dispatch(login("tricia", "secret123")).then(() => { - const actions = store.getActions(); - expect(actions[0].type).toEqual(LOGIN_PENDING); - expect(actions[1].type).toEqual(LOGIN_FAILURE); - expect(actions[1].payload).toBeDefined(); - }); + return store + .dispatch(login("/auth/access_token", "tricia", "secret123")) + .then(() => { + const actions = store.getActions(); + expect(actions[0].type).toEqual(LOGIN_PENDING); + expect(actions[1].type).toEqual(LOGIN_FAILURE); + expect(actions[1].payload).toBeDefined(); + }); }); it("should dispatch fetch me success", () => { @@ -135,7 +155,7 @@ describe("auth actions", () => { const store = mockStore({}); - return store.dispatch(fetchMe()).then(() => { + return store.dispatch(fetchMe("me")).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -146,7 +166,7 @@ describe("auth actions", () => { }); const store = mockStore({}); - return store.dispatch(fetchMe()).then(() => { + return store.dispatch(fetchMe("me")).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_ME_PENDING); expect(actions[1].type).toEqual(FETCH_ME_FAILURE); @@ -166,7 +186,7 @@ describe("auth actions", () => { const store = mockStore({}); - return store.dispatch(fetchMe()).then(() => { + return store.dispatch(fetchMe("me")).then(() => { // return of async actions expect(store.getActions()).toEqual(expectedActions); }); @@ -181,14 +201,23 @@ describe("auth actions", () => { status: 401 }); + fetchMock.getOnce("/api/v2/", { + _links: { + login: { + login: "/login" + } + } + }); + const expectedActions = [ { type: LOGOUT_PENDING }, - { type: LOGOUT_SUCCESS } + { type: LOGOUT_SUCCESS }, + { type: FETCH_INDEXRESOURCES_PENDING } ]; const store = mockStore({}); - return store.dispatch(logout()).then(() => { + return store.dispatch(logout("/auth/access_token")).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -199,7 +228,7 @@ describe("auth actions", () => { }); const store = mockStore({}); - return store.dispatch(logout()).then(() => { + return store.dispatch(logout("/auth/access_token")).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(LOGOUT_PENDING); expect(actions[1].type).toEqual(LOGOUT_FAILURE); diff --git a/scm-ui/src/modules/indexResource.js b/scm-ui/src/modules/indexResource.js new file mode 100644 index 0000000000..98dd9848dc --- /dev/null +++ b/scm-ui/src/modules/indexResource.js @@ -0,0 +1,145 @@ +// @flow +import * as types from "./types"; + +import { apiClient } from "@scm-manager/ui-components"; +import type { Action, IndexResources } from "@scm-manager/ui-types"; +import { isPending } from "./pending"; +import { getFailure } from "./failure"; + +// Action + +export const FETCH_INDEXRESOURCES = "scm/INDEXRESOURCES"; +export const FETCH_INDEXRESOURCES_PENDING = `${FETCH_INDEXRESOURCES}_${ + types.PENDING_SUFFIX +}`; +export const FETCH_INDEXRESOURCES_SUCCESS = `${FETCH_INDEXRESOURCES}_${ + types.SUCCESS_SUFFIX +}`; +export const FETCH_INDEXRESOURCES_FAILURE = `${FETCH_INDEXRESOURCES}_${ + types.FAILURE_SUFFIX +}`; + +const INDEX_RESOURCES_LINK = "/"; + +export const callFetchIndexResources = (): Promise => { + return apiClient.get(INDEX_RESOURCES_LINK).then(response => { + return response.json(); + }); +}; + +export function fetchIndexResources() { + return function(dispatch: any) { + dispatch(fetchIndexResourcesPending()); + return callFetchIndexResources() + .then(resources => { + dispatch(fetchIndexResourcesSuccess(resources)); + }) + .catch(err => { + dispatch(fetchIndexResourcesFailure(err)); + }); + }; +} + +export function fetchIndexResourcesPending(): Action { + return { + type: FETCH_INDEXRESOURCES_PENDING + }; +} + +export function fetchIndexResourcesSuccess(resources: IndexResources): Action { + return { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: resources + }; +} + +export function fetchIndexResourcesFailure(err: Error): Action { + return { + type: FETCH_INDEXRESOURCES_FAILURE, + payload: err + }; +} + +// reducer +export default function reducer( + state: Object = {}, + action: Action = { type: "UNKNOWN" } +): Object { + if (!action.payload) { + return state; + } + + switch (action.type) { + case FETCH_INDEXRESOURCES_SUCCESS: + return { + ...state, + links: action.payload._links + }; + default: + return state; + } +} + +// selectors + +export function isFetchIndexResourcesPending(state: Object) { + return isPending(state, FETCH_INDEXRESOURCES); +} + +export function getFetchIndexResourcesFailure(state: Object) { + return getFailure(state, FETCH_INDEXRESOURCES); +} + +export function getLinks(state: Object) { + return state.indexResources.links; +} + +export function getLink(state: Object, name: string) { + if (state.indexResources.links && state.indexResources.links[name]) { + return state.indexResources.links[name].href; + } +} + +export function getUiPluginsLink(state: Object) { + return getLink(state, "uiPlugins"); +} + +export function getMeLink(state: Object) { + return getLink(state, "me"); +} + +export function getLogoutLink(state: Object) { + return getLink(state, "logout"); +} + +export function getLoginLink(state: Object) { + return getLink(state, "login"); +} + +export function getUsersLink(state: Object) { + return getLink(state, "users"); +} + +export function getGroupsLink(state: Object) { + return getLink(state, "groups"); +} + +export function getConfigLink(state: Object) { + return getLink(state, "config"); +} + +export function getRepositoriesLink(state: Object) { + return getLink(state, "repositories"); +} + +export function getHgConfigLink(state: Object) { + return getLink(state, "hgConfig"); +} + +export function getGitConfigLink(state: Object) { + return getLink(state, "gitConfig"); +} + +export function getSvnConfigLink(state: Object) { + return getLink(state, "svnConfig"); +} diff --git a/scm-ui/src/modules/indexResource.test.js b/scm-ui/src/modules/indexResource.test.js new file mode 100644 index 0000000000..2199da8290 --- /dev/null +++ b/scm-ui/src/modules/indexResource.test.js @@ -0,0 +1,426 @@ +import configureMockStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import fetchMock from "fetch-mock"; +import reducer, { + FETCH_INDEXRESOURCES_PENDING, + FETCH_INDEXRESOURCES_SUCCESS, + FETCH_INDEXRESOURCES_FAILURE, + fetchIndexResources, + fetchIndexResourcesSuccess, + FETCH_INDEXRESOURCES, + isFetchIndexResourcesPending, + getFetchIndexResourcesFailure, + getUiPluginsLink, + getMeLink, + getLogoutLink, + getLoginLink, + getUsersLink, + getConfigLink, + getRepositoriesLink, + getHgConfigLink, + getGitConfigLink, + getSvnConfigLink, + getLinks, getGroupsLink +} from "./indexResource"; + +const indexResourcesUnauthenticated = { + version: "2.0.0-SNAPSHOT", + _links: { + self: { + href: "http://localhost:8081/scm/api/v2/" + }, + uiPlugins: { + href: "http://localhost:8081/scm/api/v2/ui/plugins" + }, + login: { + href: "http://localhost:8081/scm/api/v2/auth/access_token" + } + } +}; + +const indexResourcesAuthenticated = { + version: "2.0.0-SNAPSHOT", + _links: { + self: { + href: "http://localhost:8081/scm/api/v2/" + }, + uiPlugins: { + href: "http://localhost:8081/scm/api/v2/ui/plugins" + }, + me: { + href: "http://localhost:8081/scm/api/v2/me/" + }, + logout: { + href: "http://localhost:8081/scm/api/v2/auth/access_token" + }, + users: { + href: "http://localhost:8081/scm/api/v2/users/" + }, + groups: { + href: "http://localhost:8081/scm/api/v2/groups/" + }, + config: { + href: "http://localhost:8081/scm/api/v2/config" + }, + repositories: { + href: "http://localhost:8081/scm/api/v2/repositories/" + }, + hgConfig: { + href: "http://localhost:8081/scm/api/v2/config/hg" + }, + gitConfig: { + href: "http://localhost:8081/scm/api/v2/config/git" + }, + svnConfig: { + href: "http://localhost:8081/scm/api/v2/config/svn" + } + } +}; + +describe("fetch index resource", () => { + const index_url = "/api/v2/"; + const mockStore = configureMockStore([thunk]); + + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); + + it("should successfully fetch index resources when unauthenticated", () => { + fetchMock.getOnce(index_url, indexResourcesUnauthenticated); + + const expectedActions = [ + { type: FETCH_INDEXRESOURCES_PENDING }, + { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: indexResourcesUnauthenticated + } + ]; + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should successfully fetch index resources when authenticated", () => { + fetchMock.getOnce(index_url, indexResourcesAuthenticated); + + const expectedActions = [ + { type: FETCH_INDEXRESOURCES_PENDING }, + { + type: FETCH_INDEXRESOURCES_SUCCESS, + payload: indexResourcesAuthenticated + } + ]; + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => { + fetchMock.getOnce(index_url, { + status: 500 + }); + + const store = mockStore({}); + return store.dispatch(fetchIndexResources()).then(() => { + const actions = store.getActions(); + expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING); + expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE); + expect(actions[1].payload).toBeDefined(); + }); + }); +}); + +describe("index resources reducer", () => { + it("should return empty object, if state and action is undefined", () => { + expect(reducer()).toEqual({}); + }); + + it("should return the same state, if the action is undefined", () => { + const state = { x: true }; + expect(reducer(state)).toBe(state); + }); + + it("should return the same state, if the action is unknown to the reducer", () => { + const state = { x: true }; + expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state); + }); + + it("should store the index resources on FETCH_INDEXRESOURCES_SUCCESS", () => { + const newState = reducer( + {}, + fetchIndexResourcesSuccess(indexResourcesAuthenticated) + ); + expect(newState.links).toBe(indexResourcesAuthenticated._links); + }); +}); + +describe("index resources selectors", () => { + const error = new Error("something goes wrong"); + + it("should return true, when fetch index resources is pending", () => { + const state = { + pending: { + [FETCH_INDEXRESOURCES]: true + } + }; + expect(isFetchIndexResourcesPending(state)).toEqual(true); + }); + + it("should return false, when fetch index resources is not pending", () => { + expect(isFetchIndexResourcesPending({})).toEqual(false); + }); + + it("should return error when fetch index resources did fail", () => { + const state = { + failure: { + [FETCH_INDEXRESOURCES]: error + } + }; + expect(getFetchIndexResourcesFailure(state)).toEqual(error); + }); + + it("should return undefined when fetch index resources did not fail", () => { + expect(getFetchIndexResourcesFailure({})).toBe(undefined); + }); + + it("should return all links", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLinks(state)).toBe(indexResourcesAuthenticated._links); + }); + + // ui plugins link + it("should return ui plugins link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUiPluginsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/ui/plugins" + ); + }); + + it("should return ui plugins links when unauthenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getUiPluginsLink(state)).toBe( + "http://localhost:8081/scm/api/v2/ui/plugins" + ); + }); + + // me link + it("should return me link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/"); + }); + + it("should return undefined for me link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getMeLink(state)).toBe(undefined); + }); + + // logout link + it("should return logout link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLogoutLink(state)).toBe( + "http://localhost:8081/scm/api/v2/auth/access_token" + ); + }); + + it("should return undefined for logout link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getLogoutLink(state)).toBe(undefined); + }); + + // login link + it("should return login link when unauthenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getLoginLink(state)).toBe( + "http://localhost:8081/scm/api/v2/auth/access_token" + ); + }); + + it("should return undefined for login link when authenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getLoginLink(state)).toBe(undefined); + }); + + // users link + it("should return users link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getUsersLink(state)).toBe("http://localhost:8081/scm/api/v2/users/"); + }); + + it("should return undefined for users link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getUsersLink(state)).toBe(undefined); + }); + + // groups link + it("should return groups link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGroupsLink(state)).toBe("http://localhost:8081/scm/api/v2/groups/"); + }); + + it("should return undefined for groups link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getGroupsLink(state)).toBe(undefined); + }); + + // config link + it("should return config link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config" + ); + }); + + it("should return undefined for config link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getConfigLink(state)).toBe(undefined); + }); + + // repositories link + it("should return repositories link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getRepositoriesLink(state)).toBe( + "http://localhost:8081/scm/api/v2/repositories/" + ); + }); + + it("should return config for repositories link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getRepositoriesLink(state)).toBe(undefined); + }); + + // hgConfig link + it("should return hgConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getHgConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/hg" + ); + }); + + it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getHgConfigLink(state)).toBe(undefined); + }); + + // gitConfig link + it("should return gitConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getGitConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/git" + ); + }); + + it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getGitConfigLink(state)).toBe(undefined); + }); + + // svnConfig link + it("should return svnConfig link when authenticated and has permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesAuthenticated._links + } + }; + expect(getSvnConfigLink(state)).toBe( + "http://localhost:8081/scm/api/v2/config/svn" + ); + }); + + it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => { + const state = { + indexResources: { + links: indexResourcesUnauthenticated._links + } + }; + expect(getSvnConfigLink(state)).toBe(undefined); + }); +}); diff --git a/scm-ui/src/repos/components/RepositoryNavLink.js b/scm-ui/src/repos/components/RepositoryNavLink.js new file mode 100644 index 0000000000..b4cf7774af --- /dev/null +++ b/scm-ui/src/repos/components/RepositoryNavLink.js @@ -0,0 +1,30 @@ +//@flow +import React from "react"; +import type { Repository } from "@scm-manager/ui-types"; +import { NavLink } from "@scm-manager/ui-components"; + +type Props = { + repository: Repository, + to: string, + label: string, + linkName: string, + activeWhenMatch?: (route: any) => boolean, + activeOnlyWhenExact: boolean +}; + +/** + * Component renders only if the repository contains the link with the given name. + */ +class RepositoryNavLink extends React.Component { + render() { + const { repository, linkName } = this.props; + + if (!repository._links[linkName]) { + return null; + } + + return ; + } +} + +export default RepositoryNavLink; diff --git a/scm-ui/src/repos/components/RepositoryNavLink.test.js b/scm-ui/src/repos/components/RepositoryNavLink.test.js new file mode 100644 index 0000000000..0d93cb7c4d --- /dev/null +++ b/scm-ui/src/repos/components/RepositoryNavLink.test.js @@ -0,0 +1,49 @@ +// @flow +import React from "react"; +import { shallow, mount } from "enzyme"; +import "../../tests/enzyme"; +import "../../tests/i18n"; +import ReactRouterEnzymeContext from "react-router-enzyme-context"; +import RepositoryNavLink from "./RepositoryNavLink"; + +describe("RepositoryNavLink", () => { + const options = new ReactRouterEnzymeContext(); + + it("should render nothing, if the sources link is missing", () => { + const repository = { + _links: {} + }; + + const navLink = shallow( + , + options.get() + ); + expect(navLink.text()).toBe(""); + }); + + it("should render the navLink", () => { + const repository = { + _links: { + sources: { + href: "/sources" + } + } + }; + + const navLink = mount( + , + options.get() + ); + expect(navLink.text()).toBe("Sources"); + }); +}); diff --git a/scm-ui/src/repos/components/changesets/AvatarImage.js b/scm-ui/src/repos/components/changesets/AvatarImage.js new file mode 100644 index 0000000000..77792b1690 --- /dev/null +++ b/scm-ui/src/repos/components/changesets/AvatarImage.js @@ -0,0 +1,32 @@ +//@flow +import React from "react"; +import { binder } from "@scm-manager/ui-extensions"; +import type { Changeset } from "@scm-manager/ui-types"; +import { Image } from "@scm-manager/ui-components"; + +type Props = { + changeset: Changeset +}; + +class AvatarImage extends React.Component { + render() { + const { changeset } = this.props; + + const avatarFactory = binder.getExtension("changeset.avatar-factory"); + if (avatarFactory) { + const avatar = avatarFactory(changeset); + + return ( + {changeset.author.name} + ); + } + + return null; + } +} + +export default AvatarImage; diff --git a/scm-ui/src/repos/components/changesets/AvatarWrapper.js b/scm-ui/src/repos/components/changesets/AvatarWrapper.js new file mode 100644 index 0000000000..0d3d55e62a --- /dev/null +++ b/scm-ui/src/repos/components/changesets/AvatarWrapper.js @@ -0,0 +1,18 @@ +//@flow +import * as React from "react"; +import { binder } from "@scm-manager/ui-extensions"; + +type Props = { + children: React.Node +}; + +class AvatarWrapper extends React.Component { + render() { + if (binder.hasExtension("changeset.avatar-factory")) { + return <>{this.props.children}; + } + return null; + } +} + +export default AvatarWrapper; diff --git a/scm-ui/src/repos/components/changesets/ChangesetAvatar.js b/scm-ui/src/repos/components/changesets/ChangesetAvatar.js deleted file mode 100644 index 90f116daed..0000000000 --- a/scm-ui/src/repos/components/changesets/ChangesetAvatar.js +++ /dev/null @@ -1,30 +0,0 @@ -//@flow -import React from "react"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; -import type { Changeset } from "@scm-manager/ui-types"; - -type Props = { - changeset: Changeset -}; - -class ChangesetAvatar extends React.Component { - render() { - const { changeset } = this.props; - return ( - - {/* extension should render something like this: */} - {/*
*/} - {/*
*/} - {/* Logo */} - {/*
*/} - {/*
*/} -
- ); - } -} - -export default ChangesetAvatar; diff --git a/scm-ui/src/repos/components/changesets/ChangesetDetails.js b/scm-ui/src/repos/components/changesets/ChangesetDetails.js new file mode 100644 index 0000000000..a8edf0365c --- /dev/null +++ b/scm-ui/src/repos/components/changesets/ChangesetDetails.js @@ -0,0 +1,100 @@ +//@flow +import React from "react"; +import type { + Changeset, + Repository +} from "../../../../../scm-ui-components/packages/ui-types/src/index"; +import { Interpolate, translate } from "react-i18next"; +import injectSheet from "react-jss"; +import ChangesetTag from "./ChangesetTag"; +import ChangesetAuthor from "./ChangesetAuthor"; +import { parseDescription } from "./changesets"; +import { DateFromNow } from "../../../../../scm-ui-components/packages/ui-components/src/index"; +import AvatarWrapper from "./AvatarWrapper"; +import AvatarImage from "./AvatarImage"; +import classNames from "classnames"; +import ChangesetId from "./ChangesetId"; +import type { Tag } from "@scm-manager/ui-types"; + +const styles = { + spacing: { + marginRight: "1em" + } +}; + +type Props = { + changeset: Changeset, + repository: Repository, + t: string => string, + classes: any +}; + +class ChangesetDetails extends React.Component { + render() { + const { changeset, repository, classes } = this.props; + + const description = parseDescription(changeset.description); + + const id = ( + + ); + const date = ; + + return ( +
+

{description.title}

+
+ +

+ +

+
+
+

+ +

+

+ +

+
+
{this.renderTags()}
+
+

+ {description.message.split("\n").map((item, key) => { + return ( + + {item} +
+
+ ); + })} +

+
+ ); + } + + getTags = () => { + const { changeset } = this.props; + return changeset._embedded.tags || []; + }; + + renderTags = () => { + const tags = this.getTags(); + if (tags.length > 0) { + return ( +
+ {tags.map((tag: Tag) => { + return ; + })} +
+ ); + } + return null; + }; +} + +export default injectSheet(styles)(translate("repos")(ChangesetDetails)); diff --git a/scm-ui/src/repos/components/changesets/ChangesetId.js b/scm-ui/src/repos/components/changesets/ChangesetId.js index 7669cd606e..ba38e6179c 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetId.js +++ b/scm-ui/src/repos/components/changesets/ChangesetId.js @@ -6,20 +6,42 @@ import type { Repository, Changeset } from "@scm-manager/ui-types"; type Props = { repository: Repository, - changeset: Changeset + changeset: Changeset, + link: boolean }; export default class ChangesetId extends React.Component { - render() { - const { repository, changeset } = this.props; + static defaultProps = { + link: true + }; + + shortId = (changeset: Changeset) => { + return changeset.id.substr(0, 7); + }; + + renderLink = () => { + const { changeset, repository } = this.props; return ( - {changeset.id.substr(0, 7)} + {this.shortId(changeset)} ); + }; + + renderText = () => { + const { changeset } = this.props; + return this.shortId(changeset); + }; + + render() { + const { link } = this.props; + if (link) { + return this.renderLink(); + } + return this.renderText(); } } diff --git a/scm-ui/src/repos/components/changesets/ChangesetRow.js b/scm-ui/src/repos/components/changesets/ChangesetRow.js index a1a497ad67..ffe2a7eda4 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetRow.js +++ b/scm-ui/src/repos/components/changesets/ChangesetRow.js @@ -3,13 +3,15 @@ import React from "react"; import type { Changeset, Repository, Tag } from "@scm-manager/ui-types"; import classNames from "classnames"; import { translate, Interpolate } from "react-i18next"; -import ChangesetAvatar from "./ChangesetAvatar"; import ChangesetId from "./ChangesetId"; import injectSheet from "react-jss"; import { DateFromNow } from "@scm-manager/ui-components"; import ChangesetAuthor from "./ChangesetAuthor"; import ChangesetTag from "./ChangesetTag"; import { compose } from "redux"; +import { parseDescription } from "./changesets"; +import AvatarWrapper from "./AvatarWrapper"; +import AvatarImage from "./AvatarImage"; const styles = { pointer: { @@ -46,14 +48,23 @@ class ChangesetRow extends React.Component { const changesetLink = this.createLink(changeset); const dateFromNow = ; const authorLine = ; + const description = parseDescription(changeset.description); return (
- + +
+
+

+ +

+
+
+

- {changeset.description} + {description.title}
0) { + title = desc.substring(0, lineBreak); + message = desc.substring(lineBreak + 1); + } else { + title = desc; + } + + return { + title, + message + }; +} diff --git a/scm-ui/src/repos/components/changesets/changesets.test.js b/scm-ui/src/repos/components/changesets/changesets.test.js new file mode 100644 index 0000000000..ea92bcead3 --- /dev/null +++ b/scm-ui/src/repos/components/changesets/changesets.test.js @@ -0,0 +1,22 @@ +// @flow + +import { parseDescription } from "./changesets"; + +describe("parseDescription tests", () => { + it("should return a description with title and message", () => { + const desc = parseDescription("Hello\nTrillian"); + expect(desc.title).toBe("Hello"); + expect(desc.message).toBe("Trillian"); + }); + + it("should return a description with title and without message", () => { + const desc = parseDescription("Hello Trillian"); + expect(desc.title).toBe("Hello Trillian"); + }); + + it("should return an empty description for undefined", () => { + const desc = parseDescription(); + expect(desc.title).toBe(""); + expect(desc.message).toBe(""); + }); +}); diff --git a/scm-ui/src/repos/containers/BranchSelector.js b/scm-ui/src/repos/containers/BranchSelector.js index 2d8c817542..2183e13b69 100644 --- a/scm-ui/src/repos/containers/BranchSelector.js +++ b/scm-ui/src/repos/containers/BranchSelector.js @@ -17,6 +17,7 @@ const styles = { type Props = { branches: Branch[], // TODO: Use generics? selected: (branch?: Branch) => void, + selectedBranch: string, // context props classes: Object, @@ -31,6 +32,12 @@ class BranchSelector extends React.Component { this.state = {}; } + componentDidMount() { + this.props.branches + .filter(branch => branch.name === this.props.selectedBranch) + .forEach(branch => this.setState({ selectedBranch: branch })); + } + render() { const { branches, classes, t } = this.props; @@ -60,6 +67,8 @@ class BranchSelector extends React.Component {

); + } else { + return null; } } diff --git a/scm-ui/src/repos/containers/ChangesetView.js b/scm-ui/src/repos/containers/ChangesetView.js new file mode 100644 index 0000000000..b164ca03ec --- /dev/null +++ b/scm-ui/src/repos/containers/ChangesetView.js @@ -0,0 +1,75 @@ +//@flow +import React from "react"; +import { connect } from "react-redux"; +import { withRouter } from "react-router-dom"; +import type { Changeset, Repository } from "@scm-manager/ui-types"; +import { + fetchChangesetIfNeeded, + getChangeset, + getFetchChangesetFailure, + isFetchChangesetPending +} from "../modules/changesets"; +import ChangesetDetails from "../components/changesets/ChangesetDetails"; +import { translate } from "react-i18next"; +import { Loading, ErrorPage } from "@scm-manager/ui-components"; + +type Props = { + id: string, + changeset: Changeset, + repository: Repository, + loading: boolean, + error: Error, + fetchChangesetIfNeeded: (repository: Repository, id: string) => void, + match: any, + t: string => string +}; + +class ChangesetView extends React.Component { + componentDidMount() { + const { fetchChangesetIfNeeded, repository } = this.props; + const id = this.props.match.params.id; + fetchChangesetIfNeeded(repository, id); + } + + render() { + const { changeset, loading, error, t, repository } = this.props; + + if (error) { + return ( + + ); + } + + if (!changeset || loading) return ; + + return ; + } +} + +const mapStateToProps = (state, ownProps: Props) => { + const repository = ownProps.repository; + const id = ownProps.match.params.id; + const changeset = getChangeset(state, repository, id); + const loading = isFetchChangesetPending(state, repository, id); + const error = getFetchChangesetFailure(state, repository, id); + return { changeset, error, loading }; +}; + +const mapDispatchToProps = dispatch => { + return { + fetchChangesetIfNeeded: (repository: Repository, id: string) => { + dispatch(fetchChangesetIfNeeded(repository, id)); + } + }; +}; + +export default withRouter( + connect( + mapStateToProps, + mapDispatchToProps + )(translate("changesets")(ChangesetView)) +); diff --git a/scm-ui/src/repos/containers/BranchRoot.js b/scm-ui/src/repos/containers/ChangesetsRoot.js similarity index 97% rename from scm-ui/src/repos/containers/BranchRoot.js rename to scm-ui/src/repos/containers/ChangesetsRoot.js index c60dc7281a..1f3f0c1e3b 100644 --- a/scm-ui/src/repos/containers/BranchRoot.js +++ b/scm-ui/src/repos/containers/ChangesetsRoot.js @@ -92,11 +92,12 @@ class BranchRoot extends React.Component { } renderBranchSelector = () => { - const { repository, branches } = this.props; + const { repository, branches, selected } = this.props; if (repository._links.branches) { return ( { this.branchSelected(b); }} diff --git a/scm-ui/src/repos/containers/Create.js b/scm-ui/src/repos/containers/Create.js index ed1de39b75..4cf8d468de 100644 --- a/scm-ui/src/repos/containers/Create.js +++ b/scm-ui/src/repos/containers/Create.js @@ -18,16 +18,18 @@ import { isCreateRepoPending } from "../modules/repos"; import type { History } from "history"; +import { getRepositoriesLink } from "../../modules/indexResource"; type Props = { repositoryTypes: RepositoryType[], typesLoading: boolean, createLoading: boolean, error: Error, + repoLink: string, // dispatch functions fetchRepositoryTypesIfNeeded: () => void, - createRepo: (Repository, callback: () => void) => void, + createRepo: (link: string, Repository, callback: () => void) => void, resetForm: () => void, // context props @@ -55,7 +57,7 @@ class Create extends React.Component { error } = this.props; - const { t } = this.props; + const { t, repoLink } = this.props; return ( { repositoryTypes={repositoryTypes} loading={createLoading} submitForm={repo => { - createRepo(repo, this.repoCreated); + createRepo(repoLink, repo, this.repoCreated); }} /> @@ -82,11 +84,13 @@ const mapStateToProps = state => { const createLoading = isCreateRepoPending(state); const error = getFetchRepositoryTypesFailure(state) || getCreateRepoFailure(state); + const repoLink = getRepositoriesLink(state); return { repositoryTypes, typesLoading, createLoading, - error + error, + repoLink }; }; @@ -95,8 +99,12 @@ const mapDispatchToProps = dispatch => { fetchRepositoryTypesIfNeeded: () => { dispatch(fetchRepositoryTypesIfNeeded()); }, - createRepo: (repository: Repository, callback: () => void) => { - dispatch(createRepo(repository, callback)); + createRepo: ( + link: string, + repository: Repository, + callback: () => void + ) => { + dispatch(createRepo(link, repository, callback)); }, resetForm: () => { dispatch(createRepoReset()); diff --git a/scm-ui/src/repos/containers/Overview.js b/scm-ui/src/repos/containers/Overview.js index 10230b29da..bbafe14539 100644 --- a/scm-ui/src/repos/containers/Overview.js +++ b/scm-ui/src/repos/containers/Overview.js @@ -18,6 +18,7 @@ import { CreateButton, Page, Paginator } from "@scm-manager/ui-components"; import RepositoryList from "../components/list"; import { withRouter } from "react-router-dom"; import type { History } from "history"; +import { getRepositoriesLink } from "../../modules/indexResource"; type Props = { page: number, @@ -25,10 +26,11 @@ type Props = { loading: boolean, error: Error, showCreateButton: boolean, + reposLink: string, // dispatched functions - fetchRepos: () => void, - fetchReposByPage: number => void, + fetchRepos: string => void, + fetchReposByPage: (string, number) => void, fetchReposByLink: string => void, // context props @@ -38,7 +40,7 @@ type Props = { class Overview extends React.Component { componentDidMount() { - this.props.fetchReposByPage(this.props.page); + this.props.fetchReposByPage(this.props.reposLink, this.props.page); } /** @@ -113,7 +115,9 @@ const mapStateToProps = (state, ownProps) => { const loading = isFetchReposPending(state); const error = getFetchReposFailure(state); const showCreateButton = isAbleToCreateRepos(state); + const reposLink = getRepositoriesLink(state); return { + reposLink, page, collection, loading, @@ -124,11 +128,11 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = dispatch => { return { - fetchRepos: () => { - dispatch(fetchRepos()); + fetchRepos: (link: string) => { + dispatch(fetchRepos(link)); }, - fetchReposByPage: (page: number) => { - dispatch(fetchReposByPage(page)); + fetchReposByPage: (link: string, page: number) => { + dispatch(fetchReposByPage(link, page)); }, fetchReposByLink: (link: string) => { dispatch(fetchReposByLink(link)); diff --git a/scm-ui/src/repos/containers/RepositoryRoot.js b/scm-ui/src/repos/containers/RepositoryRoot.js index 681327bc96..9e6ada9a4e 100644 --- a/scm-ui/src/repos/containers/RepositoryRoot.js +++ b/scm-ui/src/repos/containers/RepositoryRoot.js @@ -7,9 +7,11 @@ import { getRepository, isFetchRepoPending } from "../modules/repos"; + import { connect } from "react-redux"; import { Route, Switch } from "react-router-dom"; import type { Repository } from "@scm-manager/ui-types"; + import { ErrorPage, Loading, @@ -26,8 +28,13 @@ import Permissions from "../permissions/containers/Permissions"; import type { History } from "history"; import EditNavLink from "../components/EditNavLink"; -import BranchRoot from "./BranchRoot"; + +import BranchRoot from "./ChangesetsRoot"; +import ChangesetView from "./ChangesetView"; import PermissionsNavLink from "../components/PermissionsNavLink"; +import Sources from "../sources/containers/Sources"; +import RepositoryNavLink from "../components/RepositoryNavLink"; +import { getRepositoriesLink } from "../../modules/indexResource"; type Props = { namespace: string, @@ -35,9 +42,10 @@ type Props = { repository: Repository, loading: boolean, error: Error, + repoLink: string, // dispatch functions - fetchRepo: (namespace: string, name: string) => void, + fetchRepo: (link: string, namespace: string, name: string) => void, deleteRepo: (repository: Repository, () => void) => void, // context props @@ -48,9 +56,9 @@ type Props = { class RepositoryRoot extends React.Component { componentDidMount() { - const { fetchRepo, namespace, name } = this.props; + const { fetchRepo, namespace, name, repoLink } = this.props; - fetchRepo(namespace, name); + fetchRepo(repoLink, namespace, name); } stripEndingSlash = (url: string) => { @@ -72,6 +80,11 @@ class RepositoryRoot extends React.Component { this.props.deleteRepo(repository, this.deleted); }; + matchChangeset = (route: any) => { + const url = this.matchedUrl(); + return route.location.pathname.match(`${url}/changeset/`); + }; + matches = (route: any) => { const url = this.matchedUrl(); const regex = new RegExp(`${url}(/branches)?/?[^/]*/changesets?.*`); @@ -119,6 +132,24 @@ class RepositoryRoot extends React.Component { /> )} /> + } + /> + ( + + )} + /> + ( + + )} + /> ( @@ -145,11 +176,20 @@ class RepositoryRoot extends React.Component {
- + { const repository = getRepository(state, namespace, name); const loading = isFetchRepoPending(state, namespace, name); const error = getFetchRepoFailure(state, namespace, name); + const repoLink = getRepositoriesLink(state); return { namespace, name, repository, loading, - error + error, + repoLink }; }; const mapDispatchToProps = dispatch => { return { - fetchRepo: (namespace: string, name: string) => { - dispatch(fetchRepo(namespace, name)); + fetchRepo: (link: string, namespace: string, name: string) => { + dispatch(fetchRepo(link, namespace, name)); }, deleteRepo: (repository: Repository, callback: () => void) => { dispatch(deleteRepo(repository, callback)); diff --git a/scm-ui/src/repos/modules/changesets.js b/scm-ui/src/repos/modules/changesets.js index 1ac83aba0a..3cd617ac56 100644 --- a/scm-ui/src/repos/modules/changesets.js +++ b/scm-ui/src/repos/modules/changesets.js @@ -5,7 +5,7 @@ import { PENDING_SUFFIX, SUCCESS_SUFFIX } from "../../modules/types"; -import { apiClient } from "@scm-manager/ui-components"; +import { apiClient, urls } from "@scm-manager/ui-components"; import { isPending } from "../../modules/pending"; import { getFailure } from "../../modules/failure"; import type { @@ -20,8 +20,76 @@ export const FETCH_CHANGESETS_PENDING = `${FETCH_CHANGESETS}_${PENDING_SUFFIX}`; export const FETCH_CHANGESETS_SUCCESS = `${FETCH_CHANGESETS}_${SUCCESS_SUFFIX}`; export const FETCH_CHANGESETS_FAILURE = `${FETCH_CHANGESETS}_${FAILURE_SUFFIX}`; -//TODO: Content type +export const FETCH_CHANGESET = "scm/repos/FETCH_CHANGESET"; +export const FETCH_CHANGESET_PENDING = `${FETCH_CHANGESET}_${PENDING_SUFFIX}`; +export const FETCH_CHANGESET_SUCCESS = `${FETCH_CHANGESET}_${SUCCESS_SUFFIX}`; +export const FETCH_CHANGESET_FAILURE = `${FETCH_CHANGESET}_${FAILURE_SUFFIX}`; + // actions +//TODO: Content type + +export function fetchChangesetIfNeeded(repository: Repository, id: string) { + return (dispatch: any, getState: any) => { + if (shouldFetchChangeset(getState(), repository, id)) { + return dispatch(fetchChangeset(repository, id)); + } + }; +} + +export function fetchChangeset(repository: Repository, id: string) { + return function(dispatch: any) { + dispatch(fetchChangesetPending(repository, id)); + return apiClient + .get(createChangesetUrl(repository, id)) + .then(response => response.json()) + .then(data => dispatch(fetchChangesetSuccess(data, repository, id))) + .catch(err => { + dispatch(fetchChangesetFailure(repository, id, err)); + }); + }; +} + +function createChangesetUrl(repository: Repository, id: string) { + return urls.concat(repository._links.changesets.href, id); +} + +export function fetchChangesetPending( + repository: Repository, + id: string +): Action { + return { + type: FETCH_CHANGESET_PENDING, + itemId: createChangesetItemId(repository, id) + }; +} + +export function fetchChangesetSuccess( + changeset: any, + repository: Repository, + id: string +): Action { + return { + type: FETCH_CHANGESET_SUCCESS, + payload: { changeset, repository, id }, + itemId: createChangesetItemId(repository, id) + }; +} + +function fetchChangesetFailure( + repository: Repository, + id: string, + error: Error +): Action { + return { + type: FETCH_CHANGESET_FAILURE, + payload: { + repository, + id, + error + }, + itemId: createChangesetItemId(repository, id) + }; +} export function fetchChangesets( repository: Repository, @@ -80,7 +148,11 @@ export function fetchChangesetsSuccess( ): Action { return { type: FETCH_CHANGESETS_SUCCESS, - payload: changesets, + payload: { + repository, + branch, + changesets + }, itemId: createItemId(repository, branch) }; } @@ -101,6 +173,11 @@ function fetchChangesetsFailure( }; } +function createChangesetItemId(repository: Repository, id: string) { + const { namespace, name } = repository; + return namespace + "/" + name + "/" + id; +} + function createItemId(repository: Repository, branch?: Branch): string { const { namespace, name } = repository; let itemId = namespace + "/" + name; @@ -118,10 +195,32 @@ export default function reducer( if (!action.payload) { return state; } + const payload = action.payload; switch (action.type) { + case FETCH_CHANGESET_SUCCESS: + const _key = createItemId(payload.repository); + + let _oldByIds = {}; + if (state[_key] && state[_key].byId) { + _oldByIds = state[_key].byId; + } + + const changeset = payload.changeset; + + return { + ...state, + [_key]: { + ...state[_key], + byId: { + ..._oldByIds, + [changeset.id]: changeset + } + } + }; + case FETCH_CHANGESETS_SUCCESS: - const changesets = payload._embedded.changesets; + const changesets = payload.changesets._embedded.changesets; const changesetIds = changesets.map(c => c.id); const key = action.itemId; @@ -129,26 +228,32 @@ export default function reducer( return state; } - let oldByIds = {}; - if (state[key] && state[key].byId) { - oldByIds = state[key].byId; + const repoId = createItemId(payload.repository); + + let oldState = {}; + if (state[repoId]) { + oldState = state[repoId]; } + const branchName = payload.branch ? payload.branch.name : ""; const byIds = extractChangesetsByIds(changesets); return { ...state, - [key]: { + [repoId]: { byId: { - ...oldByIds, + ...oldState.byId, ...byIds }, - list: { - entries: changesetIds, - entry: { - page: payload.page, - pageTotal: payload.pageTotal, - _links: payload._links + byBranch: { + ...oldState.byBranch, + [branchName]: { + entries: changesetIds, + entry: { + page: payload.changesets.page, + pageTotal: payload.changesets.pageTotal, + _links: payload.changesets._links + } } } } @@ -174,17 +279,76 @@ export function getChangesets( repository: Repository, branch?: Branch ) { - const key = createItemId(repository, branch); + const repoKey = createItemId(repository); - const changesets = state.changesets[key]; + const stateRoot = state.changesets[repoKey]; + if (!stateRoot || !stateRoot.byBranch) { + return null; + } + + const branchName = branch ? branch.name : ""; + + const changesets = stateRoot.byBranch[branchName]; if (!changesets) { return null; } - return changesets.list.entries.map((id: string) => { - return changesets.byId[id]; + + return changesets.entries.map((id: string) => { + return stateRoot.byId[id]; }); } +export function getChangeset( + state: Object, + repository: Repository, + id: string +) { + const key = createItemId(repository); + const changesets = + state.changesets && state.changesets[key] + ? state.changesets[key].byId + : null; + if (changesets != null && changesets[id]) { + return changesets[id]; + } + return null; +} + +export function shouldFetchChangeset( + state: Object, + repository: Repository, + id: string +) { + if (getChangeset(state, repository, id)) { + return false; + } + return true; +} + +export function isFetchChangesetPending( + state: Object, + repository: Repository, + id: string +) { + return isPending( + state, + FETCH_CHANGESET, + createChangesetItemId(repository, id) + ); +} + +export function getFetchChangesetFailure( + state: Object, + repository: Repository, + id: string +) { + return getFailure( + state, + FETCH_CHANGESET, + createChangesetItemId(repository, id) + ); +} + export function isFetchChangesetsPending( state: Object, repository: Repository, @@ -202,9 +366,15 @@ export function getFetchChangesetsFailure( } const selectList = (state: Object, repository: Repository, branch?: Branch) => { - const itemId = createItemId(repository, branch); - if (state.changesets[itemId] && state.changesets[itemId].list) { - return state.changesets[itemId].list; + const repoId = createItemId(repository); + + const branchName = branch ? branch.name : ""; + if (state.changesets[repoId]) { + const repoState = state.changesets[repoId]; + + if (repoState.byBranch && repoState.byBranch[branchName]) { + return repoState.byBranch[branchName]; + } } return {}; }; diff --git a/scm-ui/src/repos/modules/changesets.test.js b/scm-ui/src/repos/modules/changesets.test.js index 3b0410b635..489312688d 100644 --- a/scm-ui/src/repos/modules/changesets.test.js +++ b/scm-ui/src/repos/modules/changesets.test.js @@ -8,11 +8,23 @@ import reducer, { FETCH_CHANGESETS_FAILURE, FETCH_CHANGESETS_PENDING, FETCH_CHANGESETS_SUCCESS, + FETCH_CHANGESET, + FETCH_CHANGESET_FAILURE, + FETCH_CHANGESET_PENDING, + FETCH_CHANGESET_SUCCESS, fetchChangesets, fetchChangesetsSuccess, getChangesets, getFetchChangesetsFailure, - isFetchChangesetsPending + isFetchChangesetsPending, + fetchChangeset, + getChangeset, + fetchChangesetIfNeeded, + shouldFetchChangeset, + isFetchChangesetPending, + getFetchChangesetFailure, + fetchChangesetSuccess, + selectListAsCollection } from "./changesets"; const branch = { @@ -21,7 +33,7 @@ const branch = { _links: { history: { href: - "http://scm/api/rest/v2/repositories/foo/bar/branches/specific/changesets" + "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/changesets" } } }; @@ -32,14 +44,14 @@ const repository = { type: "GIT", _links: { self: { - href: "http://scm/api/rest/v2/repositories/foo/bar" + href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar" }, changesets: { - href: "http://scm/api/rest/v2/repositories/foo/bar/changesets" + href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/changesets" }, branches: { href: - "http://scm/api/rest/v2/repositories/foo/bar/branches/specific/branches" + "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/branches" } } }; @@ -49,9 +61,10 @@ const changesets = {}; describe("changesets", () => { describe("fetching of changesets", () => { const DEFAULT_BRANCH_URL = - "http://scm/api/rest/v2/repositories/foo/bar/changesets"; + "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/changesets"; const SPECIFIC_BRANCH_URL = - "http://scm/api/rest/v2/repositories/foo/bar/branches/specific/changesets"; + "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/changesets"; + const mockStore = configureMockStore([thunk]); afterEach(() => { @@ -59,6 +72,102 @@ describe("changesets", () => { fetchMock.restore(); }); + const changesetId = "aba876c0625d90a6aff1494f3d161aaa7008b958"; + + it("should fetch changeset", () => { + fetchMock.getOnce(DEFAULT_BRANCH_URL + "/" + changesetId, "{}"); + + const expectedActions = [ + { + type: FETCH_CHANGESET_PENDING, + itemId: "foo/bar/" + changesetId + }, + { + type: FETCH_CHANGESET_SUCCESS, + payload: { + changeset: {}, + id: changesetId, + repository: repository + }, + itemId: "foo/bar/" + changesetId + } + ]; + + const store = mockStore({}); + return store + .dispatch(fetchChangeset(repository, changesetId)) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should fail fetching changeset on error", () => { + fetchMock.getOnce(DEFAULT_BRANCH_URL + "/" + changesetId, 500); + + const expectedActions = [ + { + type: FETCH_CHANGESET_PENDING, + itemId: "foo/bar/" + changesetId + } + ]; + + const store = mockStore({}); + return store + .dispatch(fetchChangeset(repository, changesetId)) + .then(() => { + expect(store.getActions()[0]).toEqual(expectedActions[0]); + expect(store.getActions()[1].type).toEqual(FETCH_CHANGESET_FAILURE); + expect(store.getActions()[1].payload).toBeDefined(); + }); + }); + + it("should fetch changeset if needed", () => { + fetchMock.getOnce(DEFAULT_BRANCH_URL + "/id3", "{}"); + + const expectedActions = [ + { + type: FETCH_CHANGESET_PENDING, + itemId: "foo/bar/id3" + }, + { + type: FETCH_CHANGESET_SUCCESS, + payload: { + changeset: {}, + id: "id3", + repository: repository + }, + itemId: "foo/bar/id3" + } + ]; + + const store = mockStore({}); + return store + .dispatch(fetchChangesetIfNeeded(repository, "id3")) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should not fetch changeset if not needed", () => { + fetchMock.getOnce(DEFAULT_BRANCH_URL + "/id1", 500); + + const state = { + changesets: { + "foo/bar": { + byId: { + id1: { id: "id1" }, + id2: { id: "id2" } + } + } + } + }; + + const store = mockStore(state); + return expect( + store.dispatch(fetchChangesetIfNeeded(repository, "id1")) + ).toEqual(undefined); + }); + it("should fetch changesets for default branch", () => { fetchMock.getOnce(DEFAULT_BRANCH_URL, "{}"); @@ -69,7 +178,11 @@ describe("changesets", () => { }, { type: FETCH_CHANGESETS_SUCCESS, - payload: changesets, + payload: { + repository, + undefined, + changesets + }, itemId: "foo/bar" } ]; @@ -91,7 +204,11 @@ describe("changesets", () => { }, { type: FETCH_CHANGESETS_SUCCESS, - payload: changesets, + payload: { + repository, + branch, + changesets + }, itemId } ]; @@ -150,7 +267,11 @@ describe("changesets", () => { }, { type: FETCH_CHANGESETS_SUCCESS, - payload: changesets, + payload: { + repository, + undefined, + changesets + }, itemId: "foo/bar" } ]; @@ -173,7 +294,11 @@ describe("changesets", () => { }, { type: FETCH_CHANGESETS_SUCCESS, - payload: changesets, + payload: { + repository, + branch, + changesets + }, itemId: "foo/bar/specific" } ]; @@ -215,7 +340,7 @@ describe("changesets", () => { ); expect(newState["foo/bar"].byId["changeset2"].description).toEqual("foo"); expect(newState["foo/bar"].byId["changeset3"].description).toEqual("bar"); - expect(newState["foo/bar"].list).toEqual({ + expect(newState["foo/bar"].byBranch[""]).toEqual({ entry: { page: 1, pageTotal: 10, @@ -225,6 +350,20 @@ describe("changesets", () => { }); }); + it("should store the changeset list to branch", () => { + const newState = reducer( + {}, + fetchChangesetsSuccess(repository, branch, responseBody) + ); + + expect(newState["foo/bar"].byId["changeset1"]).toBeDefined(); + expect(newState["foo/bar"].byBranch["specific"].entries).toEqual([ + "changeset1", + "changeset2", + "changeset3" + ]); + }); + it("should not remove existing changesets", () => { const state = { "foo/bar": { @@ -232,8 +371,10 @@ describe("changesets", () => { id2: { id: "id2" }, id1: { id: "id1" } }, - list: { - entries: ["id1", "id2"] + byBranch: { + "": { + entries: ["id1", "id2"] + } } } }; @@ -245,7 +386,7 @@ describe("changesets", () => { const fooBar = newState["foo/bar"]; - expect(fooBar.list.entries).toEqual([ + expect(fooBar.byBranch[""].entries).toEqual([ "changeset1", "changeset2", "changeset3" @@ -253,11 +394,154 @@ describe("changesets", () => { expect(fooBar.byId["id2"]).toEqual({ id: "id2" }); expect(fooBar.byId["id1"]).toEqual({ id: "id1" }); }); + + const responseBodySingleChangeset = { + id: "id3", + author: { + mail: "z@phod.com", + name: "zaphod" + }, + date: "2018-09-13T08:46:22Z", + description: "added testChangeset", + _links: {}, + _embedded: { + tags: [], + branches: [] + } + }; + + it("should add changeset to state", () => { + const newState = reducer( + { + "foo/bar": { + byId: { + "id2": { + id: "id2", + author: { mail: "mail@author.com", name: "author" } + } + }, + list: { + entry: { + page: 1, + pageTotal: 10, + _links: {} + }, + entries: ["id2"] + } + } + }, + fetchChangesetSuccess(responseBodySingleChangeset, repository, "id3") + ); + + expect(newState).toBeDefined(); + expect(newState["foo/bar"].byId["id3"].description).toEqual( + "added testChangeset" + ); + expect(newState["foo/bar"].byId["id3"].author.mail).toEqual("z@phod.com"); + expect(newState["foo/bar"].byId["id2"]).toBeDefined(); + expect(newState["foo/bar"].byId["id3"]).toBeDefined(); + expect(newState["foo/bar"].list).toEqual({ + entry: { + page: 1, + pageTotal: 10, + _links: {} + }, + entries: ["id2"] + }); + }); }); describe("changeset selectors", () => { const error = new Error("Something went wrong"); + it("should return changeset", () => { + const state = { + changesets: { + "foo/bar": { + byId: { + id1: { id: "id1" }, + id2: { id: "id2" } + } + } + } + }; + const result = getChangeset(state, repository, "id1"); + expect(result).toEqual({ id: "id1" }); + }); + + it("should return null if changeset does not exist", () => { + const state = { + changesets: { + "foo/bar": { + byId: { + id1: { id: "id1" }, + id2: { id: "id2" } + } + } + } + }; + const result = getChangeset(state, repository, "id3"); + expect(result).toEqual(null); + }); + + it("should return true if changeset does not exist", () => { + const state = { + changesets: { + "foo/bar": { + byId: { + id1: { id: "id1" }, + id2: { id: "id2" } + } + } + } + }; + const result = shouldFetchChangeset(state, repository, "id3"); + expect(result).toEqual(true); + }); + + it("should return false if changeset exists", () => { + const state = { + changesets: { + "foo/bar": { + byId: { + id1: { id: "id1" }, + id2: { id: "id2" } + } + } + } + }; + const result = shouldFetchChangeset(state, repository, "id2"); + expect(result).toEqual(false); + }); + + it("should return true, when fetching changeset is pending", () => { + const state = { + pending: { + [FETCH_CHANGESET + "/foo/bar/id1"]: true + } + }; + + expect(isFetchChangesetPending(state, repository, "id1")).toBeTruthy(); + }); + + it("should return false, when fetching changeset is not pending", () => { + expect(isFetchChangesetPending({}, repository, "id1")).toEqual(false); + }); + + it("should return error if fetching changeset failed", () => { + const state = { + failure: { + [FETCH_CHANGESET + "/foo/bar/id1"]: error + } + }; + + expect(getFetchChangesetFailure(state, repository, "id1")).toEqual(error); + }); + + it("should return false if fetching changeset did not fail", () => { + expect(getFetchChangesetFailure({}, repository, "id1")).toBeUndefined(); + }); + it("should get all changesets for a given repository", () => { const state = { changesets: { @@ -266,8 +550,10 @@ describe("changesets", () => { id2: { id: "id2" }, id1: { id: "id1" } }, - list: { - entries: ["id1", "id2"] + byBranch: { + "": { + entries: ["id1", "id2"] + } } } } @@ -303,5 +589,32 @@ describe("changesets", () => { it("should return false if fetching changesets did not fail", () => { expect(getFetchChangesetsFailure({}, repository)).toBeUndefined(); }); + + it("should return list as collection for the default branch", () => { + const state = { + changesets: { + "foo/bar": { + byId: { + id2: { id: "id2" }, + id1: { id: "id1" } + }, + byBranch: { + "": { + entry: { + page: 1, + pageTotal: 10, + _links: {} + }, + entries: ["id1", "id2"] + } + } + } + } + }; + + const collection = selectListAsCollection(state, repository); + expect(collection.page).toBe(1); + expect(collection.pageTotal).toBe(10); + }); }); }); diff --git a/scm-ui/src/repos/modules/repos.js b/scm-ui/src/repos/modules/repos.js index 1fb769f851..b5016bbb43 100644 --- a/scm-ui/src/repos/modules/repos.js +++ b/scm-ui/src/repos/modules/repos.js @@ -35,20 +35,18 @@ export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`; export const DELETE_REPO_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`; export const DELETE_REPO_FAILURE = `${DELETE_REPO}_${types.FAILURE_SUFFIX}`; -const REPOS_URL = "repositories"; - const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2"; // fetch repos const SORT_BY = "sortBy=namespaceAndName"; -export function fetchRepos() { - return fetchReposByLink(REPOS_URL); +export function fetchRepos(link: string) { + return fetchReposByLink(link); } -export function fetchReposByPage(page: number) { - return fetchReposByLink(`${REPOS_URL}?page=${page - 1}`); +export function fetchReposByPage(link: string, page: number) { + return fetchReposByLink(`${link}?page=${page - 1}`); } function appendSortByLink(url: string) { @@ -102,11 +100,12 @@ export function fetchReposFailure(err: Error): Action { // fetch repo -export function fetchRepo(namespace: string, name: string) { +export function fetchRepo(link: string, namespace: string, name: string) { + const repoUrl = link.endsWith("/") ? link : link + "/"; return function(dispatch: any) { dispatch(fetchRepoPending(namespace, name)); return apiClient - .get(`${REPOS_URL}/${namespace}/${name}`) + .get(`${repoUrl}${namespace}/${name}`) .then(response => response.json()) .then(repository => { dispatch(fetchRepoSuccess(repository)); @@ -154,11 +153,15 @@ export function fetchRepoFailure( // create repo -export function createRepo(repository: Repository, callback?: () => void) { +export function createRepo( + link: string, + repository: Repository, + callback?: () => void +) { return function(dispatch: any) { dispatch(createRepoPending()); return apiClient - .post(REPOS_URL, repository, CONTENT_TYPE) + .post(link, repository, CONTENT_TYPE) .then(() => { dispatch(createRepoSuccess()); if (callback) { @@ -448,3 +451,12 @@ export function getDeleteRepoFailure( ) { return getFailure(state, DELETE_REPO, namespace + "/" + name); } + +export function getPermissionsLink( + state: Object, + namespace: string, + name: string +) { + const repo = getRepository(state, namespace, name); + return repo && repo._links ? repo._links.permissions.href : undefined; +} diff --git a/scm-ui/src/repos/modules/repos.test.js b/scm-ui/src/repos/modules/repos.test.js index 302918f02e..5b5c2d3abd 100644 --- a/scm-ui/src/repos/modules/repos.test.js +++ b/scm-ui/src/repos/modules/repos.test.js @@ -45,7 +45,8 @@ import reducer, { MODIFY_REPO, isModifyRepoPending, getModifyRepoFailure, - modifyRepoSuccess + modifyRepoSuccess, + getPermissionsLink } from "./repos"; import type { Repository, RepositoryCollection } from "@scm-manager/ui-types"; @@ -99,16 +100,13 @@ const hitchhikerRestatend: Repository = { type: "git", _links: { self: { - href: - "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" + href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" }, delete: { - href: - "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" + href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" }, update: { - href: - "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" + href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend" }, permissions: { href: @@ -158,16 +156,14 @@ const slartiFjords: Repository = { href: "http://localhost:8081/api/v2/repositories/slarti/fjords/tags/" }, branches: { - href: - "http://localhost:8081/api/v2/repositories/slarti/fjords/branches/" + href: "http://localhost:8081/api/v2/repositories/slarti/fjords/branches/" }, changesets: { href: "http://localhost:8081/api/v2/repositories/slarti/fjords/changesets/" }, sources: { - href: - "http://localhost:8081/api/v2/repositories/slarti/fjords/sources/" + href: "http://localhost:8081/api/v2/repositories/slarti/fjords/sources/" } } }; @@ -221,6 +217,7 @@ const repositoryCollectionWithNames: RepositoryCollection = { }; describe("repos fetch", () => { + const URL = "repositories"; const REPOS_URL = "/api/v2/repositories"; const SORT = "sortBy=namespaceAndName"; const REPOS_URL_WITH_SORT = REPOS_URL + "?" + SORT; @@ -243,7 +240,7 @@ describe("repos fetch", () => { ]; const store = mockStore({}); - return store.dispatch(fetchRepos()).then(() => { + return store.dispatch(fetchRepos(URL)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -262,7 +259,7 @@ describe("repos fetch", () => { const store = mockStore({}); - return store.dispatch(fetchReposByPage(43)).then(() => { + return store.dispatch(fetchReposByPage(URL, 43)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -318,7 +315,7 @@ describe("repos fetch", () => { }); const store = mockStore({}); - return store.dispatch(fetchRepos()).then(() => { + return store.dispatch(fetchRepos(URL)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_REPOS_PENDING); expect(actions[1].type).toEqual(FETCH_REPOS_FAILURE); @@ -346,7 +343,7 @@ describe("repos fetch", () => { ]; const store = mockStore({}); - return store.dispatch(fetchRepo("slarti", "fjords")).then(() => { + return store.dispatch(fetchRepo(URL, "slarti", "fjords")).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -357,7 +354,7 @@ describe("repos fetch", () => { }); const store = mockStore({}); - return store.dispatch(fetchRepo("slarti", "fjords")).then(() => { + return store.dispatch(fetchRepo(URL, "slarti", "fjords")).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(FETCH_REPO_PENDING); expect(actions[1].type).toEqual(FETCH_REPO_FAILURE); @@ -383,7 +380,7 @@ describe("repos fetch", () => { ]; const store = mockStore({}); - return store.dispatch(createRepo(slartiFjords)).then(() => { + return store.dispatch(createRepo(URL, slartiFjords)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); @@ -400,7 +397,7 @@ describe("repos fetch", () => { }; const store = mockStore({}); - return store.dispatch(createRepo(slartiFjords, callback)).then(() => { + return store.dispatch(createRepo(URL, slartiFjords, callback)).then(() => { expect(callMe).toBe("yeah"); }); }); @@ -411,7 +408,7 @@ describe("repos fetch", () => { }); const store = mockStore({}); - return store.dispatch(createRepo(slartiFjords)).then(() => { + return store.dispatch(createRepo(URL, slartiFjords)).then(() => { const actions = store.getActions(); expect(actions[0].type).toEqual(CREATE_REPO_PENDING); expect(actions[1].type).toEqual(CREATE_REPO_FAILURE); @@ -649,6 +646,21 @@ describe("repos selectors", () => { expect(repository).toEqual(slartiFjords); }); + it("should return permissions link", () => { + const state = { + repos: { + byNames: { + "slarti/fjords": slartiFjords + } + } + }; + + const link = getPermissionsLink(state, "slarti", "fjords"); + expect(link).toEqual( + "http://localhost:8081/api/v2/repositories/slarti/fjords/permissions/" + ); + }); + it("should return true, when fetch repo is pending", () => { const state = { pending: { diff --git a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js index 595c27d8ef..0bc42fac9f 100644 --- a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js +++ b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js @@ -5,13 +5,13 @@ import { Checkbox, InputField, SubmitButton } from "@scm-manager/ui-components"; import TypeSelector from "./TypeSelector"; import type { PermissionCollection, - PermissionEntry + PermissionCreateEntry } from "@scm-manager/ui-types"; import * as validator from "./permissionValidation"; type Props = { t: string => string, - createPermission: (permission: PermissionEntry) => void, + createPermission: (permission: PermissionCreateEntry) => void, loading: boolean, currentPermissions: PermissionCollection }; @@ -51,21 +51,24 @@ class CreatePermissionForm extends React.Component { onChange={this.handleNameChange} validationError={!this.state.valid} errorMessage={t("permission.add-permission.name-input-invalid")} + helpText={t("permission.help.nameHelpText")} />
diff --git a/scm-ui/src/repos/permissions/components/TypeSelector.js b/scm-ui/src/repos/permissions/components/TypeSelector.js index 89319581d0..de1950fa78 100644 --- a/scm-ui/src/repos/permissions/components/TypeSelector.js +++ b/scm-ui/src/repos/permissions/components/TypeSelector.js @@ -7,13 +7,15 @@ type Props = { t: string => string, handleTypeChange: string => void, type: string, + label?: string, + helpText?: string, loading?: boolean }; class TypeSelector extends React.Component { render() { - const { type, handleTypeChange, loading } = this.props; - const types = ["READ", "WRITE", "OWNER"]; + const { type, handleTypeChange, loading, label, helpText } = this.props; + const types = ["READ", "OWNER", "WRITE"]; return (