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 ;
+ const { alt, className } = this.props;
+ return ;
}
}
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 (
+
+ );
+ }
+
+ 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: */}
- {/* */}
- {/* */}
- {/* */}
- {/* */}
- {/*
*/}
-
- );
- }
-}
-
-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 (
{
value={type ? type : "READ"}
options={this.createSelectOptions(types)}
loading={loading}
+ label={label}
+ helpText={helpText}
/>
);
}
@@ -35,4 +39,4 @@ class TypeSelector extends React.Component {
}
}
-export default translate("permissions")(TypeSelector);
+export default translate("repos")(TypeSelector);
diff --git a/scm-ui/src/repos/permissions/components/permissionValidation.js b/scm-ui/src/repos/permissions/components/permissionValidation.js
index b74ae40988..d63ab72d54 100644
--- a/scm-ui/src/repos/permissions/components/permissionValidation.js
+++ b/scm-ui/src/repos/permissions/components/permissionValidation.js
@@ -1,21 +1,30 @@
// @flow
import { validation } from "@scm-manager/ui-components";
-import type {
- PermissionCollection,
-} from "@scm-manager/ui-types";
+import type { PermissionCollection } from "@scm-manager/ui-types";
const isNameValid = validation.isNameValid;
export { isNameValid };
-export const isPermissionValid = (name: string, groupPermission: boolean, permissions: PermissionCollection) => {
- return isNameValid(name) && !currentPermissionIncludeName(name, groupPermission, permissions);
+export const isPermissionValid = (
+ name: string,
+ groupPermission: boolean,
+ permissions: PermissionCollection
+) => {
+ return (
+ isNameValid(name) &&
+ !currentPermissionIncludeName(name, groupPermission, permissions)
+ );
};
-const currentPermissionIncludeName = (name: string, groupPermission: boolean, permissions: PermissionCollection) => {
+const currentPermissionIncludeName = (
+ name: string,
+ groupPermission: boolean,
+ permissions: PermissionCollection
+) => {
for (let i = 0; i < permissions.length; i++) {
if (
permissions[i].name === name &&
- permissions[i].groupPermission == groupPermission
+ permissions[i].groupPermission === groupPermission
)
return true;
}
diff --git a/scm-ui/src/repos/permissions/components/permissionValidation.test.js b/scm-ui/src/repos/permissions/components/permissionValidation.test.js
index 036375a348..b2e7e8be68 100644
--- a/scm-ui/src/repos/permissions/components/permissionValidation.test.js
+++ b/scm-ui/src/repos/permissions/components/permissionValidation.test.js
@@ -17,7 +17,8 @@ describe("permission validation", () => {
{
name: "PermissionName",
groupPermission: true,
- type: "READ"
+ type: "READ",
+ _links: {}
}
];
const name = "PermissionName";
@@ -33,7 +34,8 @@ describe("permission validation", () => {
{
name: "PermissionName",
groupPermission: false,
- type: "READ"
+ type: "READ",
+ _links: {}
}
];
const name = "PermissionName";
diff --git a/scm-ui/src/repos/permissions/containers/Permissions.js b/scm-ui/src/repos/permissions/containers/Permissions.js
index e70e02f443..ee9ac281a5 100644
--- a/scm-ui/src/repos/permissions/containers/Permissions.js
+++ b/scm-ui/src/repos/permissions/containers/Permissions.js
@@ -21,11 +21,12 @@ import { Loading, ErrorPage } from "@scm-manager/ui-components";
import type {
Permission,
PermissionCollection,
- PermissionEntry
+ PermissionCreateEntry
} from "@scm-manager/ui-types";
import SinglePermission from "./SinglePermission";
import CreatePermissionForm from "../components/CreatePermissionForm";
import type { History } from "history";
+import { getPermissionsLink } from "../../modules/repos";
type Props = {
namespace: string,
@@ -35,11 +36,13 @@ type Props = {
permissions: PermissionCollection,
hasPermissionToCreate: boolean,
loadingCreatePermission: boolean,
+ permissionsLink: string,
//dispatch functions
- fetchPermissions: (namespace: string, repoName: string) => void,
+ fetchPermissions: (link: string, namespace: string, repoName: string) => void,
createPermission: (
- permission: PermissionEntry,
+ link: string,
+ permission: PermissionCreateEntry,
namespace: string,
repoName: string,
callback?: () => void
@@ -61,17 +64,19 @@ class Permissions extends React.Component {
repoName,
modifyPermissionReset,
createPermissionReset,
- deletePermissionReset
+ deletePermissionReset,
+ permissionsLink
} = this.props;
createPermissionReset(namespace, repoName);
modifyPermissionReset(namespace, repoName);
deletePermissionReset(namespace, repoName);
- fetchPermissions(namespace, repoName);
+ fetchPermissions(permissionsLink, namespace, repoName);
}
createPermission = (permission: Permission) => {
this.props.createPermission(
+ this.props.permissionsLink,
permission,
this.props.namespace,
this.props.repoName
@@ -159,6 +164,7 @@ const mapStateToProps = (state, ownProps) => {
repoName
);
const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName);
+ const permissionsLink = getPermissionsLink(state, namespace, repoName);
return {
namespace,
repoName,
@@ -166,22 +172,24 @@ const mapStateToProps = (state, ownProps) => {
loading,
permissions,
hasPermissionToCreate,
- loadingCreatePermission
+ loadingCreatePermission,
+ permissionsLink
};
};
const mapDispatchToProps = dispatch => {
return {
- fetchPermissions: (namespace: string, repoName: string) => {
- dispatch(fetchPermissions(namespace, repoName));
+ fetchPermissions: (link: string, namespace: string, repoName: string) => {
+ dispatch(fetchPermissions(link, namespace, repoName));
},
createPermission: (
- permission: PermissionEntry,
+ link: string,
+ permission: PermissionCreateEntry,
namespace: string,
repoName: string,
callback?: () => void
) => {
- dispatch(createPermission(permission, namespace, repoName, callback));
+ dispatch(createPermission(link, permission, namespace, repoName, callback));
},
createPermissionReset: (namespace: string, repoName: string) => {
dispatch(createPermissionReset(namespace, repoName));
diff --git a/scm-ui/src/repos/permissions/modules/permissions.js b/scm-ui/src/repos/permissions/modules/permissions.js
index 86d78e7ae9..465d7a4ee0 100644
--- a/scm-ui/src/repos/permissions/modules/permissions.js
+++ b/scm-ui/src/repos/permissions/modules/permissions.js
@@ -6,7 +6,7 @@ import type { Action } from "@scm-manager/ui-components";
import type {
PermissionCollection,
Permission,
- PermissionEntry
+ PermissionCreateEntry
} from "@scm-manager/ui-types";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
@@ -62,17 +62,19 @@ export const DELETE_PERMISSION_RESET = `${DELETE_PERMISSION}_${
types.RESET_SUFFIX
}`;
-const REPOS_URL = "repositories";
-const PERMISSIONS_URL = "permissions";
const CONTENT_TYPE = "application/vnd.scmm-permission+json";
// fetch permissions
-export function fetchPermissions(namespace: string, repoName: string) {
+export function fetchPermissions(
+ link: string,
+ namespace: string,
+ repoName: string
+) {
return function(dispatch: any) {
dispatch(fetchPermissionsPending(namespace, repoName));
return apiClient
- .get(`${REPOS_URL}/${namespace}/${repoName}/${PERMISSIONS_URL}`)
+ .get(link)
.then(response => response.json())
.then(permissions => {
dispatch(fetchPermissionsSuccess(permissions, namespace, repoName));
@@ -219,7 +221,8 @@ export function modifyPermissionReset(namespace: string, repoName: string) {
// create permission
export function createPermission(
- permission: PermissionEntry,
+ link: string,
+ permission: PermissionCreateEntry,
namespace: string,
repoName: string,
callback?: () => void
@@ -227,11 +230,7 @@ export function createPermission(
return function(dispatch: Dispatch) {
dispatch(createPermissionPending(permission, namespace, repoName));
return apiClient
- .post(
- `${REPOS_URL}/${namespace}/${repoName}/${PERMISSIONS_URL}`,
- permission,
- CONTENT_TYPE
- )
+ .post(link, permission, CONTENT_TYPE)
.then(response => {
const location = response.headers.get("Location");
return apiClient.get(location);
@@ -260,7 +259,7 @@ export function createPermission(
}
export function createPermissionPending(
- permission: PermissionEntry,
+ permission: PermissionCreateEntry,
namespace: string,
repoName: string
): Action {
@@ -272,7 +271,7 @@ export function createPermissionPending(
}
export function createPermissionSuccess(
- permission: PermissionEntry,
+ permission: PermissionCreateEntry,
namespace: string,
repoName: string
): Action {
diff --git a/scm-ui/src/repos/permissions/modules/permissions.test.js b/scm-ui/src/repos/permissions/modules/permissions.test.js
index e546f6cb00..3043a7db29 100644
--- a/scm-ui/src/repos/permissions/modules/permissions.test.js
+++ b/scm-ui/src/repos/permissions/modules/permissions.test.js
@@ -101,6 +101,7 @@ const hitchhiker_puzzle42RepoPermissions = {
describe("permission fetch", () => {
const REPOS_URL = "/api/v2/repositories";
+ const URL = "repositories";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
@@ -132,7 +133,13 @@ describe("permission fetch", () => {
const store = mockStore({});
return store
- .dispatch(fetchPermissions("hitchhiker", "puzzle42"))
+ .dispatch(
+ fetchPermissions(
+ URL + "/hitchhiker/puzzle42/permissions",
+ "hitchhiker",
+ "puzzle42"
+ )
+ )
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
@@ -145,7 +152,13 @@ describe("permission fetch", () => {
const store = mockStore({});
return store
- .dispatch(fetchPermissions("hitchhiker", "puzzle42"))
+ .dispatch(
+ fetchPermissions(
+ URL + "/hitchhiker/puzzle42/permissions",
+ "hitchhiker",
+ "puzzle42"
+ )
+ )
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_PERMISSIONS_PENDING);
@@ -247,6 +260,7 @@ describe("permission fetch", () => {
return store
.dispatch(
createPermission(
+ URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42"
@@ -268,6 +282,7 @@ describe("permission fetch", () => {
return store
.dispatch(
createPermission(
+ URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42"
@@ -304,6 +319,7 @@ describe("permission fetch", () => {
return store
.dispatch(
createPermission(
+ URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins,
"hitchhiker",
"puzzle42",
@@ -640,7 +656,7 @@ describe("permissions selectors", () => {
it("should return true, when createPermission is true", () => {
const state = {
permissions: {
- ["hitchhiker/puzzle42"]: {
+ "hitchhiker/puzzle42": {
createPermission: true
}
}
@@ -651,7 +667,7 @@ describe("permissions selectors", () => {
it("should return false, when createPermission is false", () => {
const state = {
permissions: {
- ["hitchhiker/puzzle42"]: {
+ "hitchhiker/puzzle42": {
createPermission: false
}
}
diff --git a/scm-ui/src/repos/sources/components/FileIcon.js b/scm-ui/src/repos/sources/components/FileIcon.js
new file mode 100644
index 0000000000..97aa5bc6f6
--- /dev/null
+++ b/scm-ui/src/repos/sources/components/FileIcon.js
@@ -0,0 +1,22 @@
+// @flow
+import React from "react";
+import type { File } from "@scm-manager/ui-types";
+
+type Props = {
+ file: File
+};
+
+class FileIcon extends React.Component {
+ render() {
+ const { file } = this.props;
+ let icon = "file";
+ if (file.subRepository) {
+ icon = "folder-plus";
+ } else if (file.directory) {
+ icon = "folder";
+ }
+ return ;
+ }
+}
+
+export default FileIcon;
diff --git a/scm-ui/src/repos/sources/components/FileSize.js b/scm-ui/src/repos/sources/components/FileSize.js
new file mode 100644
index 0000000000..c7d966d901
--- /dev/null
+++ b/scm-ui/src/repos/sources/components/FileSize.js
@@ -0,0 +1,27 @@
+// @flow
+import React from "react";
+
+type Props = {
+ bytes: number
+};
+
+class FileSize extends React.Component {
+ static format(bytes: number) {
+ if (!bytes) {
+ return "0 B";
+ }
+
+ const units = ["B", "K", "M", "G", "T", "P", "E", "Z", "Y"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+
+ const size = i === 0 ? bytes : (bytes / 1024 ** i).toFixed(2);
+ return `${size} ${units[i]}`;
+ }
+
+ render() {
+ const formattedBytes = FileSize.format(this.props.bytes);
+ return {formattedBytes} ;
+ }
+}
+
+export default FileSize;
diff --git a/scm-ui/src/repos/sources/components/FileSize.test.js b/scm-ui/src/repos/sources/components/FileSize.test.js
new file mode 100644
index 0000000000..8ecb53e1bb
--- /dev/null
+++ b/scm-ui/src/repos/sources/components/FileSize.test.js
@@ -0,0 +1,10 @@
+import FileSize from "./FileSize";
+
+it("should format bytes", () => {
+ expect(FileSize.format(0)).toBe("0 B");
+ expect(FileSize.format(160)).toBe("160 B");
+ expect(FileSize.format(6304)).toBe("6.16 K");
+ expect(FileSize.format(28792588)).toBe("27.46 M");
+ expect(FileSize.format(1369510189)).toBe("1.28 G");
+ expect(FileSize.format(42949672960)).toBe("40.00 G");
+});
diff --git a/scm-ui/src/repos/sources/components/FileTree.js b/scm-ui/src/repos/sources/components/FileTree.js
new file mode 100644
index 0000000000..02aa22f942
--- /dev/null
+++ b/scm-ui/src/repos/sources/components/FileTree.js
@@ -0,0 +1,184 @@
+//@flow
+import React from "react";
+import { translate } from "react-i18next";
+import { connect } from "react-redux";
+import injectSheet from "react-jss";
+import FileTreeLeaf from "./FileTreeLeaf";
+import type { Repository, File } from "@scm-manager/ui-types";
+import { ErrorNotification, Loading } from "@scm-manager/ui-components";
+import {
+ fetchSources,
+ getFetchSourcesFailure,
+ isFetchSourcesPending,
+ getSources
+} from "../modules/sources";
+import { withRouter } from "react-router-dom";
+import { compose } from "redux";
+
+const styles = {
+ iconColumn: {
+ width: "16px"
+ }
+};
+
+type Props = {
+ loading: boolean,
+ error: Error,
+ tree: File,
+ repository: Repository,
+ revision: string,
+ path: string,
+ baseUrl: string,
+ fetchSources: (Repository, string, string) => void,
+ // context props
+ classes: any,
+ t: string => string,
+ match: any
+};
+
+export function findParent(path: string) {
+ if (path.endsWith("/")) {
+ path = path.substring(0, path.length - 1);
+ }
+
+ const index = path.lastIndexOf("/");
+ if (index > 0) {
+ return path.substring(0, index);
+ }
+ return "";
+}
+
+class FileTree extends React.Component {
+ componentDidMount() {
+ const { fetchSources, repository, revision, path } = this.props;
+
+ fetchSources(repository, revision, path);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { fetchSources, repository, revision, path } = this.props;
+ if (prevProps.revision !== revision || prevProps.path !== path) {
+ fetchSources(repository, revision, path);
+ }
+ }
+
+ render() {
+ const {
+ error,
+ loading,
+ tree,
+ revision,
+ path,
+ baseUrl,
+ classes,
+ t
+ } = this.props;
+
+ const compareFiles = function(f1: File, f2: File): number {
+ if (f1.directory) {
+ if (f2.directory) {
+ return f1.name.localeCompare(f2.name);
+ } else {
+ return -1;
+ }
+ } else {
+ if (f2.directory) {
+ return 1;
+ } else {
+ return f1.name.localeCompare(f2.name);
+ }
+ }
+ };
+
+ if (error) {
+ return ;
+ }
+
+ if (loading) {
+ return ;
+ }
+ if (!tree) {
+ return null;
+ }
+
+ const files = [];
+
+ if (path) {
+ files.push({
+ name: "..",
+ path: findParent(path),
+ directory: true
+ });
+ }
+
+ if (tree._embedded) {
+ files.push(...tree._embedded.children.sort(compareFiles));
+ }
+
+ let baseUrlWithRevision = baseUrl;
+ if (revision) {
+ baseUrlWithRevision += "/" + encodeURIComponent(revision);
+ } else {
+ baseUrlWithRevision += "/" + encodeURIComponent(tree.revision);
+ }
+
+ return (
+
+
+
+
+ {t("sources.file-tree.name")}
+
+ {t("sources.file-tree.length")}
+
+
+ {t("sources.file-tree.lastModified")}
+
+ {t("sources.file-tree.description")}
+
+
+
+ {files.map(file => (
+
+ ))}
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state: any, ownProps: Props) => {
+ const { repository, revision, path } = ownProps;
+
+ const loading = isFetchSourcesPending(state, repository, revision, path);
+ const error = getFetchSourcesFailure(state, repository, revision, path);
+ const tree = getSources(state, repository, revision, path);
+
+ return {
+ revision,
+ path,
+ loading,
+ error,
+ tree
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchSources: (repository: Repository, revision: string, path: string) => {
+ dispatch(fetchSources(repository, revision, path));
+ }
+ };
+};
+
+export default compose(
+ withRouter,
+ connect(
+ mapStateToProps,
+ mapDispatchToProps
+ )
+)(injectSheet(styles)(translate("repos")(FileTree)));
diff --git a/scm-ui/src/repos/sources/components/FileTree.test.js b/scm-ui/src/repos/sources/components/FileTree.test.js
new file mode 100644
index 0000000000..77977df5bc
--- /dev/null
+++ b/scm-ui/src/repos/sources/components/FileTree.test.js
@@ -0,0 +1,12 @@
+// @flow
+
+import { findParent } from "./FileTree";
+
+describe("find parent tests", () => {
+ it("should return the parent path", () => {
+ expect(findParent("src/main/js/")).toBe("src/main");
+ expect(findParent("src/main/js")).toBe("src/main");
+ expect(findParent("src/main")).toBe("src");
+ expect(findParent("src")).toBe("");
+ });
+});
diff --git a/scm-ui/src/repos/sources/components/FileTreeLeaf.js b/scm-ui/src/repos/sources/components/FileTreeLeaf.js
new file mode 100644
index 0000000000..033d3b9b8a
--- /dev/null
+++ b/scm-ui/src/repos/sources/components/FileTreeLeaf.js
@@ -0,0 +1,81 @@
+//@flow
+import * as React from "react";
+import injectSheet from "react-jss";
+import { DateFromNow } from "@scm-manager/ui-components";
+import FileSize from "./FileSize";
+import FileIcon from "./FileIcon";
+import { Link } from "react-router-dom";
+import type { File } from "@scm-manager/ui-types";
+
+const styles = {
+ iconColumn: {
+ width: "16px"
+ }
+};
+
+type Props = {
+ file: File,
+ baseUrl: string,
+
+ // context props
+ classes: any
+};
+
+export function createLink(base: string, file: File) {
+ let link = base;
+ if (file.path) {
+ let path = file.path;
+ if (path.startsWith("/")) {
+ path = path.substring(1);
+ }
+ link += "/" + path;
+ }
+ if (!link.endsWith("/")) {
+ link += "/";
+ }
+ return link;
+}
+
+class FileTreeLeaf extends React.Component {
+ createLink = (file: File) => {
+ return createLink(this.props.baseUrl, file);
+ };
+
+ createFileIcon = (file: File) => {
+ if (file.directory) {
+ return (
+
+
+
+ );
+ }
+ return ;
+ };
+
+ createFileName = (file: File) => {
+ if (file.directory) {
+ return {file.name};
+ }
+ return file.name;
+ };
+
+ render() {
+ const { file, classes } = this.props;
+
+ const fileSize = file.directory ? "" : ;
+
+ return (
+
+ {this.createFileIcon(file)}
+ {this.createFileName(file)}
+ {fileSize}
+
+
+
+ {file.description}
+
+ );
+ }
+}
+
+export default injectSheet(styles)(FileTreeLeaf);
diff --git a/scm-ui/src/repos/sources/components/FileTreeLeaf.test.js b/scm-ui/src/repos/sources/components/FileTreeLeaf.test.js
new file mode 100644
index 0000000000..d5004521c8
--- /dev/null
+++ b/scm-ui/src/repos/sources/components/FileTreeLeaf.test.js
@@ -0,0 +1,24 @@
+// @flow
+
+import { createLink } from "./FileTreeLeaf";
+import type { File } from "@scm-manager/ui-types";
+
+describe("create link tests", () => {
+ function dir(path: string): File {
+ return {
+ name: "dir",
+ path: path,
+ directory: true
+ };
+ }
+
+ it("should create link", () => {
+ expect(createLink("src", dir("main"))).toBe("src/main/");
+ expect(createLink("src", dir("/main"))).toBe("src/main/");
+ expect(createLink("src", dir("/main/"))).toBe("src/main/");
+ });
+
+ it("should return base url if the directory path is empty", () => {
+ expect(createLink("src", dir(""))).toBe("src/");
+ });
+});
diff --git a/scm-ui/src/repos/sources/containers/Sources.js b/scm-ui/src/repos/sources/containers/Sources.js
new file mode 100644
index 0000000000..cf072e958e
--- /dev/null
+++ b/scm-ui/src/repos/sources/containers/Sources.js
@@ -0,0 +1,131 @@
+// @flow
+import React from "react";
+import { connect } from "react-redux";
+import { withRouter } from "react-router-dom";
+import type { Repository, Branch } from "@scm-manager/ui-types";
+import FileTree from "../components/FileTree";
+import { ErrorNotification, Loading } from "@scm-manager/ui-components";
+import BranchSelector from "../../containers/BranchSelector";
+import {
+ fetchBranches,
+ getBranches,
+ getFetchBranchesFailure,
+ isFetchBranchesPending
+} from "../../modules/branches";
+import { compose } from "redux";
+
+type Props = {
+ repository: Repository,
+ loading: boolean,
+ error: Error,
+ baseUrl: string,
+ branches: Branch[],
+ revision: string,
+ path: string,
+
+ // dispatch props
+ fetchBranches: Repository => void,
+
+ // Context props
+ history: any,
+ match: any
+};
+
+class Sources extends React.Component {
+ componentDidMount() {
+ const { fetchBranches, repository } = this.props;
+
+ fetchBranches(repository);
+ }
+
+ branchSelected = (branch?: Branch) => {
+ const { baseUrl, history, path } = this.props;
+
+ let url;
+ if (branch) {
+ if (path) {
+ url = `${baseUrl}/${encodeURIComponent(branch.name)}/${path}`;
+ } else {
+ url = `${baseUrl}/${encodeURIComponent(branch.name)}/`;
+ }
+ } else {
+ url = `${baseUrl}/`;
+ }
+ history.push(url);
+ };
+
+ render() {
+ const { repository, baseUrl, loading, error, revision, path } = this.props;
+
+ if (error) {
+ return ;
+ }
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+ <>
+ {this.renderBranchSelector()}
+
+ >
+ );
+ }
+
+ renderBranchSelector = () => {
+ const { repository, branches, revision } = this.props;
+ if (repository._links.branches) {
+ return (
+ {
+ this.branchSelected(b);
+ }}
+ />
+ );
+ }
+ return null;
+ };
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const { repository, match } = ownProps;
+ const { revision, path } = match.params;
+ const decodedRevision = revision ? decodeURIComponent(revision) : undefined;
+
+ const loading = isFetchBranchesPending(state, repository);
+ const error = getFetchBranchesFailure(state, repository);
+ const branches = getBranches(state, repository);
+
+ return {
+ repository,
+ revision: decodedRevision,
+ path,
+ loading,
+ error,
+ branches
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchBranches: (repository: Repository) => {
+ dispatch(fetchBranches(repository));
+ }
+ };
+};
+
+export default compose(
+ withRouter,
+ connect(
+ mapStateToProps,
+ mapDispatchToProps
+ )
+)(Sources);
diff --git a/scm-ui/src/repos/sources/modules/sources.js b/scm-ui/src/repos/sources/modules/sources.js
new file mode 100644
index 0000000000..719770d75c
--- /dev/null
+++ b/scm-ui/src/repos/sources/modules/sources.js
@@ -0,0 +1,141 @@
+// @flow
+
+import * as types from "../../../modules/types";
+import type { Repository, File, Action } from "@scm-manager/ui-types";
+import { apiClient } from "@scm-manager/ui-components";
+import { isPending } from "../../../modules/pending";
+import { getFailure } from "../../../modules/failure";
+
+export const FETCH_SOURCES = "scm/repos/FETCH_SOURCES";
+export const FETCH_SOURCES_PENDING = `${FETCH_SOURCES}_${types.PENDING_SUFFIX}`;
+export const FETCH_SOURCES_SUCCESS = `${FETCH_SOURCES}_${types.SUCCESS_SUFFIX}`;
+export const FETCH_SOURCES_FAILURE = `${FETCH_SOURCES}_${types.FAILURE_SUFFIX}`;
+
+export function fetchSources(
+ repository: Repository,
+ revision: string,
+ path: string
+) {
+ return function(dispatch: any) {
+ dispatch(fetchSourcesPending(repository, revision, path));
+ return apiClient
+ .get(createUrl(repository, revision, path))
+ .then(response => response.json())
+ .then(sources => {
+ dispatch(fetchSourcesSuccess(repository, revision, path, sources));
+ })
+ .catch(err => {
+ const error = new Error(`failed to fetch sources: ${err.message}`);
+ dispatch(fetchSourcesFailure(repository, revision, path, error));
+ });
+ };
+}
+
+function createUrl(repository: Repository, revision: string, path: string) {
+ const base = repository._links.sources.href;
+ if (!revision && !path) {
+ return base;
+ }
+
+ // TODO handle trailing slash
+ const pathDefined = path ? path : "";
+ return `${base}${encodeURIComponent(revision)}/${pathDefined}`;
+}
+
+export function fetchSourcesPending(
+ repository: Repository,
+ revision: string,
+ path: string
+): Action {
+ return {
+ type: FETCH_SOURCES_PENDING,
+ itemId: createItemId(repository, revision, path)
+ };
+}
+
+export function fetchSourcesSuccess(
+ repository: Repository,
+ revision: string,
+ path: string,
+ sources: File
+) {
+ return {
+ type: FETCH_SOURCES_SUCCESS,
+ payload: sources,
+ itemId: createItemId(repository, revision, path)
+ };
+}
+
+export function fetchSourcesFailure(
+ repository: Repository,
+ revision: string,
+ path: string,
+ error: Error
+): Action {
+ return {
+ type: FETCH_SOURCES_FAILURE,
+ payload: error,
+ itemId: createItemId(repository, revision, path)
+ };
+}
+
+function createItemId(repository: Repository, revision: string, path: string) {
+ const revPart = revision ? revision : "_";
+ const pathPart = path ? path : "";
+ return `${repository.namespace}/${repository.name}/${revPart}/${pathPart}`;
+}
+
+// reducer
+
+export default function reducer(
+ state: any = {},
+ action: Action = { type: "UNKNOWN" }
+): any {
+ if (action.type === FETCH_SOURCES_SUCCESS) {
+ return {
+ [action.itemId]: action.payload,
+ ...state
+ };
+ }
+ return state;
+}
+
+// selectors
+
+export function getSources(
+ state: any,
+ repository: Repository,
+ revision: string,
+ path: string
+): ?File {
+ if (state.sources) {
+ return state.sources[createItemId(repository, revision, path)];
+ }
+ return null;
+}
+
+export function isFetchSourcesPending(
+ state: any,
+ repository: Repository,
+ revision: string,
+ path: string
+): boolean {
+ return isPending(
+ state,
+ FETCH_SOURCES,
+ createItemId(repository, revision, path)
+ );
+}
+
+export function getFetchSourcesFailure(
+ state: any,
+ repository: Repository,
+ revision: string,
+ path: string
+): ?Error {
+ return getFailure(
+ state,
+ FETCH_SOURCES,
+ createItemId(repository, revision, path)
+ );
+}
diff --git a/scm-ui/src/repos/sources/modules/sources.test.js b/scm-ui/src/repos/sources/modules/sources.test.js
new file mode 100644
index 0000000000..068fa39e8f
--- /dev/null
+++ b/scm-ui/src/repos/sources/modules/sources.test.js
@@ -0,0 +1,220 @@
+// @flow
+
+import type { Repository } from "@scm-manager/ui-types";
+import configureMockStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import fetchMock from "fetch-mock";
+import {
+ FETCH_SOURCES,
+ FETCH_SOURCES_FAILURE,
+ FETCH_SOURCES_PENDING,
+ FETCH_SOURCES_SUCCESS,
+ fetchSources,
+ getFetchSourcesFailure,
+ isFetchSourcesPending,
+ default as reducer,
+ getSources,
+ fetchSourcesSuccess
+} from "./sources";
+
+const sourcesUrl =
+ "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/";
+
+const repository: Repository = {
+ name: "core",
+ namespace: "scm",
+ type: "git",
+ _links: {
+ sources: {
+ href: sourcesUrl
+ }
+ }
+};
+
+const collection = {
+ revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4",
+ _links: {
+ self: {
+ href:
+ "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/"
+ }
+ },
+ _embedded: {
+ files: [
+ {
+ name: "src",
+ path: "src",
+ directory: true,
+ description: null,
+ length: 176,
+ lastModified: null,
+ subRepository: null,
+ _links: {
+ self: {
+ href:
+ "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/src"
+ }
+ }
+ },
+ {
+ name: "package.json",
+ path: "package.json",
+ directory: false,
+ description: "bump version",
+ length: 780,
+ lastModified: "2017-07-31T11:17:19Z",
+ subRepository: null,
+ _links: {
+ self: {
+ href:
+ "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/content/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/package.json"
+ },
+ history: {
+ href:
+ "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/history/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/package.json"
+ }
+ }
+ }
+ ]
+ }
+};
+
+describe("sources fetch", () => {
+ const mockStore = configureMockStore([thunk]);
+
+ afterEach(() => {
+ fetchMock.reset();
+ fetchMock.restore();
+ });
+
+ it("should fetch the sources of the repository", () => {
+ fetchMock.getOnce(sourcesUrl, collection);
+
+ const expectedActions = [
+ { type: FETCH_SOURCES_PENDING, itemId: "scm/core/_/" },
+ {
+ type: FETCH_SOURCES_SUCCESS,
+ itemId: "scm/core/_/",
+ payload: collection
+ }
+ ];
+
+ const store = mockStore({});
+ return store.dispatch(fetchSources(repository)).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should fetch the sources of the repository with the given revision and path", () => {
+ fetchMock.getOnce(sourcesUrl + "abc/src", collection);
+
+ const expectedActions = [
+ { type: FETCH_SOURCES_PENDING, itemId: "scm/core/abc/src" },
+ {
+ type: FETCH_SOURCES_SUCCESS,
+ itemId: "scm/core/abc/src",
+ payload: collection
+ }
+ ];
+
+ const store = mockStore({});
+ return store.dispatch(fetchSources(repository, "abc", "src")).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should dispatch FETCH_SOURCES_FAILURE on server error", () => {
+ fetchMock.getOnce(sourcesUrl, {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(fetchSources(repository)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toBe(FETCH_SOURCES_PENDING);
+ expect(actions[1].type).toBe(FETCH_SOURCES_FAILURE);
+ expect(actions[1].itemId).toBe("scm/core/_/");
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+});
+
+describe("reducer tests", () => {
+ it("should return unmodified state on unknown action", () => {
+ const state = {};
+ expect(reducer(state)).toBe(state);
+ });
+
+ it("should store the collection, without revision and path", () => {
+ const expectedState = {
+ "scm/core/_/": collection
+ };
+ expect(
+ reducer({}, fetchSourcesSuccess(repository, null, null, collection))
+ ).toEqual(expectedState);
+ });
+
+ it("should store the collection, with revision and path", () => {
+ const expectedState = {
+ "scm/core/abc/src/main": collection
+ };
+ expect(
+ reducer(
+ {},
+ fetchSourcesSuccess(repository, "abc", "src/main", collection)
+ )
+ ).toEqual(expectedState);
+ });
+});
+
+describe("selector tests", () => {
+ it("should return null", () => {
+ expect(getSources({}, repository)).toBeFalsy();
+ });
+
+ it("should return the source collection without revision and path", () => {
+ const state = {
+ sources: {
+ "scm/core/_/": collection
+ }
+ };
+ expect(getSources(state, repository)).toBe(collection);
+ });
+
+ it("should return the source collection without revision and path", () => {
+ const state = {
+ sources: {
+ "scm/core/abc/src/main": collection
+ }
+ };
+ expect(getSources(state, repository, "abc", "src/main")).toBe(collection);
+ });
+
+ it("should return true, when fetch sources is pending", () => {
+ const state = {
+ pending: {
+ [FETCH_SOURCES + "/scm/core/_/"]: true
+ }
+ };
+ expect(isFetchSourcesPending(state, repository)).toEqual(true);
+ });
+
+ it("should return false, when fetch sources is not pending", () => {
+ expect(isFetchSourcesPending({}, repository)).toEqual(false);
+ });
+
+ const error = new Error("incredible error from hell");
+
+ it("should return error when fetch sources did fail", () => {
+ const state = {
+ failure: {
+ [FETCH_SOURCES + "/scm/core/_/"]: error
+ }
+ };
+ expect(getFetchSourcesFailure(state, repository)).toEqual(error);
+ });
+
+ it("should return undefined when fetch sources did not fail", () => {
+ expect(getFetchSourcesFailure({}, repository)).toBe(undefined);
+ });
+});
diff --git a/scm-ui/src/users/containers/AddUser.js b/scm-ui/src/users/containers/AddUser.js
index 1d89730972..1ee6fc759d 100644
--- a/scm-ui/src/users/containers/AddUser.js
+++ b/scm-ui/src/users/containers/AddUser.js
@@ -12,13 +12,15 @@ import {
} from "../modules/users";
import { Page } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
+import {getUsersLink} from "../../modules/indexResource";
type Props = {
loading?: boolean,
error?: Error,
+ usersLink: string,
// dispatcher functions
- addUser: (user: User, callback?: () => void) => void,
+ addUser: (link: string, user: User, callback?: () => void) => void,
resetForm: () => void,
// context objects
@@ -37,7 +39,7 @@ class AddUser extends React.Component {
};
createUser = (user: User) => {
- this.props.addUser(user, this.userCreated);
+ this.props.addUser(this.props.usersLink, user, this.userCreated);
};
render() {
@@ -61,8 +63,8 @@ class AddUser extends React.Component {
const mapDispatchToProps = dispatch => {
return {
- addUser: (user: User, callback?: () => void) => {
- dispatch(createUser(user, callback));
+ addUser: (link: string, user: User, callback?: () => void) => {
+ dispatch(createUser(link, user, callback));
},
resetForm: () => {
dispatch(createUserReset());
@@ -73,7 +75,9 @@ const mapDispatchToProps = dispatch => {
const mapStateToProps = (state, ownProps) => {
const loading = isCreateUserPending(state);
const error = getCreateUserFailure(state);
+ const usersLink = getUsersLink(state);
return {
+ usersLink,
loading,
error
};
diff --git a/scm-ui/src/users/containers/SingleUser.js b/scm-ui/src/users/containers/SingleUser.js
index 2e6308da2b..f581874742 100644
--- a/scm-ui/src/users/containers/SingleUser.js
+++ b/scm-ui/src/users/containers/SingleUser.js
@@ -26,16 +26,18 @@ import {
import { DeleteUserNavLink, EditUserNavLink } from "./../components/navLinks";
import { translate } from "react-i18next";
+import { getUsersLink } from "../../modules/indexResource";
type Props = {
name: string,
user: User,
loading: boolean,
error: Error,
+ usersLink: string,
// dispatcher functions
deleteUser: (user: User, callback?: () => void) => void,
- fetchUser: string => void,
+ fetchUser: (string, string) => void,
// context objects
t: string => string,
@@ -45,7 +47,7 @@ type Props = {
class SingleUser extends React.Component {
componentDidMount() {
- this.props.fetchUser(this.props.name);
+ this.props.fetchUser(this.props.usersLink, this.props.name);
}
userDeleted = () => {
@@ -124,8 +126,9 @@ const mapStateToProps = (state, ownProps) => {
isFetchUserPending(state, name) || isDeleteUserPending(state, name);
const error =
getFetchUserFailure(state, name) || getDeleteUserFailure(state, name);
-
+ const usersLink = getUsersLink(state);
return {
+ usersLink,
name,
user,
loading,
@@ -135,8 +138,8 @@ const mapStateToProps = (state, ownProps) => {
const mapDispatchToProps = dispatch => {
return {
- fetchUser: (name: string) => {
- dispatch(fetchUser(name));
+ fetchUser: (link: string, name: string) => {
+ dispatch(fetchUser(link, name));
},
deleteUser: (user: User, callback?: () => void) => {
dispatch(deleteUser(user, callback));
diff --git a/scm-ui/src/users/containers/Users.js b/scm-ui/src/users/containers/Users.js
index e43667b324..48c20c88ec 100644
--- a/scm-ui/src/users/containers/Users.js
+++ b/scm-ui/src/users/containers/Users.js
@@ -18,6 +18,7 @@ import { Page, Paginator } from "@scm-manager/ui-components";
import { UserTable } from "./../components/table";
import type { User, PagedCollection } from "@scm-manager/ui-types";
import CreateUserButton from "../components/buttons/CreateUserButton";
+import { getUsersLink } from "../../modules/indexResource";
type Props = {
users: User[],
@@ -26,19 +27,20 @@ type Props = {
canAddUsers: boolean,
list: PagedCollection,
page: number,
+ usersLink: string,
// context objects
t: string => string,
history: History,
// dispatch functions
- fetchUsersByPage: (page: number) => void,
+ fetchUsersByPage: (link: string, page: number) => void,
fetchUsersByLink: (link: string) => void
};
class Users extends React.Component {
componentDidMount() {
- this.props.fetchUsersByPage(this.props.page);
+ this.props.fetchUsersByPage(this.props.usersLink, this.props.page);
}
onPageChange = (link: string) => {
@@ -107,6 +109,8 @@ const mapStateToProps = (state, ownProps) => {
const loading = isFetchUsersPending(state);
const error = getFetchUsersFailure(state);
+ const usersLink = getUsersLink(state);
+
const page = getPageFromProps(ownProps);
const canAddUsers = isPermittedToCreateUsers(state);
const list = selectListAsCollection(state);
@@ -117,14 +121,15 @@ const mapStateToProps = (state, ownProps) => {
error,
canAddUsers,
list,
- page
+ page,
+ usersLink
};
};
const mapDispatchToProps = dispatch => {
return {
- fetchUsersByPage: (page: number) => {
- dispatch(fetchUsersByPage(page));
+ fetchUsersByPage: (link: string, page: number) => {
+ dispatch(fetchUsersByPage(link, page));
},
fetchUsersByLink: (link: string) => {
dispatch(fetchUsersByLink(link));
diff --git a/scm-ui/src/users/modules/users.js b/scm-ui/src/users/modules/users.js
index 2d6cba7f03..80d8107b3e 100644
--- a/scm-ui/src/users/modules/users.js
+++ b/scm-ui/src/users/modules/users.js
@@ -32,21 +32,19 @@ export const DELETE_USER_PENDING = `${DELETE_USER}_${types.PENDING_SUFFIX}`;
export const DELETE_USER_SUCCESS = `${DELETE_USER}_${types.SUCCESS_SUFFIX}`;
export const DELETE_USER_FAILURE = `${DELETE_USER}_${types.FAILURE_SUFFIX}`;
-const USERS_URL = "users";
-
const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2";
// TODO i18n for error messages
// fetch users
-export function fetchUsers() {
- return fetchUsersByLink(USERS_URL);
+export function fetchUsers(link: string) {
+ return fetchUsersByLink(link);
}
-export function fetchUsersByPage(page: number) {
+export function fetchUsersByPage(link: string, page: number) {
// backend start counting by 0
- return fetchUsersByLink(USERS_URL + "?page=" + (page - 1));
+ return fetchUsersByLink(link + "?page=" + (page - 1));
}
export function fetchUsersByLink(link: string) {
@@ -60,7 +58,7 @@ export function fetchUsersByLink(link: string) {
})
.catch(cause => {
const error = new Error(`could not fetch users: ${cause.message}`);
- dispatch(fetchUsersFailure(USERS_URL, error));
+ dispatch(fetchUsersFailure(link, error));
});
};
}
@@ -89,8 +87,8 @@ export function fetchUsersFailure(url: string, error: Error): Action {
}
//fetch user
-export function fetchUser(name: string) {
- const userUrl = USERS_URL + "/" + name;
+export function fetchUser(link: string, name: string) {
+ const userUrl = link.endsWith("/") ? link + name : link + "/" + name;
return function(dispatch: any) {
dispatch(fetchUserPending(name));
return apiClient
@@ -137,11 +135,11 @@ export function fetchUserFailure(name: string, error: Error): Action {
//create user
-export function createUser(user: User, callback?: () => void) {
+export function createUser(link: string, user: User, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(createUserPending(user));
return apiClient
- .post(USERS_URL, user, CONTENT_TYPE_USER)
+ .post(link, user, CONTENT_TYPE_USER)
.then(() => {
dispatch(createUserSuccess());
if (callback) {
diff --git a/scm-ui/src/users/modules/users.test.js b/scm-ui/src/users/modules/users.test.js
index c61d288c94..03da4658c2 100644
--- a/scm-ui/src/users/modules/users.test.js
+++ b/scm-ui/src/users/modules/users.test.js
@@ -122,6 +122,7 @@ const response = {
responseBody
};
+const URL = "users";
const USERS_URL = "/api/v2/users";
const error = new Error("KAPUTT");
@@ -146,7 +147,7 @@ describe("users fetch()", () => {
const store = mockStore({});
- return store.dispatch(fetchUsers()).then(() => {
+ return store.dispatch(fetchUsers(URL)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
@@ -157,7 +158,7 @@ describe("users fetch()", () => {
});
const store = mockStore({});
- return store.dispatch(fetchUsers()).then(() => {
+ return store.dispatch(fetchUsers(URL)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USERS_PENDING);
expect(actions[1].type).toEqual(FETCH_USERS_FAILURE);
@@ -169,7 +170,7 @@ describe("users fetch()", () => {
fetchMock.getOnce(USERS_URL + "/zaphod", userZaphod);
const store = mockStore({});
- return store.dispatch(fetchUser("zaphod")).then(() => {
+ return store.dispatch(fetchUser(URL, "zaphod")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USER_PENDING);
expect(actions[1].type).toEqual(FETCH_USER_SUCCESS);
@@ -183,7 +184,7 @@ describe("users fetch()", () => {
});
const store = mockStore({});
- return store.dispatch(fetchUser("zaphod")).then(() => {
+ return store.dispatch(fetchUser(URL, "zaphod")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USER_PENDING);
expect(actions[1].type).toEqual(FETCH_USER_FAILURE);
@@ -201,7 +202,7 @@ describe("users fetch()", () => {
fetchMock.getOnce(USERS_URL, response);
const store = mockStore({});
- return store.dispatch(createUser(userZaphod)).then(() => {
+ return store.dispatch(createUser(URL, userZaphod)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_USER_PENDING);
expect(actions[1].type).toEqual(CREATE_USER_SUCCESS);
@@ -214,7 +215,7 @@ describe("users fetch()", () => {
});
const store = mockStore({});
- return store.dispatch(createUser(userZaphod)).then(() => {
+ return store.dispatch(createUser(URL, userZaphod)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_USER_PENDING);
expect(actions[1].type).toEqual(CREATE_USER_FAILURE);
@@ -235,7 +236,7 @@ describe("users fetch()", () => {
};
const store = mockStore({});
- return store.dispatch(createUser(userZaphod, callback)).then(() => {
+ return store.dispatch(createUser(URL, userZaphod, callback)).then(() => {
expect(callMe).toBe("yeah");
});
});
diff --git a/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java b/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java
index 6b4a29961d..222800c9e3 100644
--- a/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java
+++ b/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java
@@ -23,6 +23,7 @@ import java.net.URL;
* @since 2.0.0
*/
@Singleton
+@Priority(WebResourceServlet.PRIORITY)
@WebElement(value = WebResourceServlet.PATTERN, regex = true)
public class WebResourceServlet extends HttpServlet {
@@ -35,6 +36,9 @@ public class WebResourceServlet extends HttpServlet {
@VisibleForTesting
static final String PATTERN = "/(?!api/|git/|hg/|svn/|hook/|repo/).*";
+ // Be sure that this servlet is the last one in the servlet chain.
+ static final int PRIORITY = Integer.MAX_VALUE;
+
private static final Logger LOG = LoggerFactory.getLogger(WebResourceServlet.class);
private final WebResourceSender sender = WebResourceSender.create()
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultDto.java
deleted file mode 100644
index 2b49f18fa1..0000000000
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultDto.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package sonia.scm.api.v2.resources;
-
-import de.otto.edison.hal.HalRepresentation;
-import de.otto.edison.hal.Links;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-import lombok.Setter;
-
-import java.util.List;
-
-@Getter
-@Setter
-@NoArgsConstructor
-public class BrowserResultDto extends HalRepresentation {
- private String revision;
-
- @Override
- @SuppressWarnings("squid:S1185") // We want to have this method available in this package
- protected HalRepresentation add(Links links) {
- return super.add(links);
- }
-
- public void setFiles(List files) {
- this.withEmbedded("files", files);
- }
-}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapper.java
deleted file mode 100644
index c877cb0647..0000000000
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapper.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package sonia.scm.api.v2.resources;
-
-import de.otto.edison.hal.Links;
-import sonia.scm.repository.BrowserResult;
-import sonia.scm.repository.FileObject;
-import sonia.scm.repository.NamespaceAndName;
-
-import javax.inject.Inject;
-import java.util.ArrayList;
-import java.util.List;
-
-public class BrowserResultToBrowserResultDtoMapper {
-
- @Inject
- private FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper;
-
- @Inject
- private ResourceLinks resourceLinks;
-
- public BrowserResultDto map(BrowserResult browserResult, NamespaceAndName namespaceAndName, String path) {
- BrowserResultDto browserResultDto = new BrowserResultDto();
-
- browserResultDto.setRevision(browserResult.getRevision());
-
- List fileObjectDtoList = new ArrayList<>();
- for (FileObject fileObject : browserResult.getFiles()) {
- fileObjectDtoList.add(mapFileObject(fileObject, namespaceAndName, browserResult.getRevision()));
- }
-
- browserResultDto.setFiles(fileObjectDtoList);
- this.addLinks(browserResult, browserResultDto, namespaceAndName, path);
- return browserResultDto;
- }
-
- private FileObjectDto mapFileObject(FileObject fileObject, NamespaceAndName namespaceAndName, String revision) {
- return fileObjectToFileObjectDtoMapper.map(fileObject, namespaceAndName, revision);
- }
-
- private void addLinks(BrowserResult browserResult, BrowserResultDto dto, NamespaceAndName namespaceAndName, String path) {
- if (path.equals("/")) {
- path = "";
- }
- if (browserResult.getRevision() == null) {
- throw new IllegalStateException("missing revision in browser result for repository " + namespaceAndName + " and path " + path);
- } else {
- dto.add(Links.linkingTo().self(resourceLinks.source().sourceWithPath(namespaceAndName.getNamespace(), namespaceAndName.getName(), browserResult.getRevision(), path)).build());
- }
- }
-}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java
new file mode 100644
index 0000000000..9720fb5b1a
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java
@@ -0,0 +1,22 @@
+package sonia.scm.api.v2.resources;
+
+import sonia.scm.repository.BrowserResult;
+import sonia.scm.repository.NamespaceAndName;
+
+import javax.inject.Inject;
+
+public class BrowserResultToFileObjectDtoMapper {
+
+ private final FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper;
+
+ @Inject
+ public BrowserResultToFileObjectDtoMapper(FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper) {
+ this.fileObjectToFileObjectDtoMapper = fileObjectToFileObjectDtoMapper;
+ }
+
+ public FileObjectDto map(BrowserResult browserResult, NamespaceAndName namespaceAndName) {
+ FileObjectDto fileObjectDto = fileObjectToFileObjectDtoMapper.map(browserResult.getFile(), namespaceAndName, browserResult.getRevision());
+ fileObjectDto.setRevision( browserResult.getRevision() );
+ return fileObjectDto;
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java
index ab4986554a..c183d731c6 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java
@@ -1,5 +1,6 @@
package sonia.scm.api.v2.resources;
+import com.fasterxml.jackson.annotation.JsonInclude;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
@@ -7,6 +8,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
+import java.util.List;
@Getter
@Setter
@@ -15,14 +17,26 @@ public class FileObjectDto extends HalRepresentation {
private String name;
private String path;
private boolean directory;
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
private String description;
- private int length;
+ private long length;
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
private Instant lastModified;
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
private SubRepositoryDto subRepository;
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private String revision;
@Override
@SuppressWarnings("squid:S1185") // We want to have this method available in this package
protected HalRepresentation add(Links links) {
return super.add(links);
}
+
+ public void setChildren(List children) {
+ if (!children.isEmpty()) {
+ // prevent empty embedded attribute in json
+ this.withEmbedded("children", children);
+ }
+ }
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java
index 365c0ad4cb..7ba8d21c75 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java
@@ -12,6 +12,9 @@ import sonia.scm.repository.SubRepository;
import javax.inject.Inject;
+import java.util.List;
+import java.util.stream.Collectors;
+
import static de.otto.edison.hal.Link.link;
@Mapper
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
index 2b37eda9b7..1b397480a4 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
@@ -4,6 +4,7 @@ import sonia.scm.repository.NamespaceAndName;
import javax.inject.Inject;
import java.net.URI;
+import java.net.URISyntaxException;
class ResourceLinks {
@@ -16,7 +17,11 @@ class ResourceLinks {
// we have to add the file path using URI, so that path separators (aka '/') will not be encoded as '%2F'
private static String addPath(String sourceWithPath, String path) {
- return URI.create(sourceWithPath).resolve(path).toASCIIString();
+ try {
+ return new URI(sourceWithPath).resolve(new URI(null, null, path, null)).toASCIIString();
+ } catch (URISyntaxException e) {
+ throw new RuntimeException(e);
+ }
}
GroupLinks group() {
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java
index 01a25f461c..d507a59f14 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java
@@ -18,13 +18,13 @@ import java.io.IOException;
public class SourceRootResource {
private final RepositoryServiceFactory serviceFactory;
- private final BrowserResultToBrowserResultDtoMapper browserResultToBrowserResultDtoMapper;
+ private final BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper;
@Inject
- public SourceRootResource(RepositoryServiceFactory serviceFactory, BrowserResultToBrowserResultDtoMapper browserResultToBrowserResultDtoMapper) {
+ public SourceRootResource(RepositoryServiceFactory serviceFactory, BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper) {
this.serviceFactory = serviceFactory;
- this.browserResultToBrowserResultDtoMapper = browserResultToBrowserResultDtoMapper;
+ this.browserResultToFileObjectDtoMapper = browserResultToFileObjectDtoMapper;
}
@GET
@@ -56,10 +56,11 @@ public class SourceRootResource {
if (revision != null && !revision.isEmpty()) {
browseCommand.setRevision(revision);
}
+ browseCommand.setDisableCache(true);
BrowserResult browserResult = browseCommand.getBrowserResult();
if (browserResult != null) {
- return Response.ok(browserResultToBrowserResultDtoMapper.map(browserResult, namespaceAndName, path)).build();
+ return Response.ok(browserResultToFileObjectDtoMapper.map(browserResult, namespaceAndName)).build();
} else {
return Response.status(Response.Status.NOT_FOUND).build();
}
diff --git a/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java b/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java
new file mode 100644
index 0000000000..f80b90e22a
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java
@@ -0,0 +1,188 @@
+package sonia.scm.web.i18n;
+
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.github.legman.Subscribe;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.inject.Singleton;
+import lombok.extern.slf4j.Slf4j;
+import sonia.scm.NotFoundException;
+import sonia.scm.SCMContext;
+import sonia.scm.Stage;
+import sonia.scm.boot.RestartEvent;
+import sonia.scm.cache.Cache;
+import sonia.scm.cache.CacheManager;
+import sonia.scm.filter.WebElement;
+import sonia.scm.plugin.PluginLoader;
+
+import javax.inject.Inject;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.URL;
+import java.util.Enumeration;
+import java.util.Iterator;
+import java.util.Optional;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+
+/**
+ * Collect the plugin translations.
+ */
+@Singleton
+@WebElement(value = I18nServlet.PATTERN, regex = true)
+@Slf4j
+public class I18nServlet extends HttpServlet {
+
+ public static final String PLUGINS_JSON = "plugins.json";
+ public static final String PATTERN = "/locales/[a-z\\-A-Z]*/" + PLUGINS_JSON;
+ public static final String CACHE_NAME = "sonia.cache.plugins.translations";
+
+ private final ClassLoader classLoader;
+ private final Cache cache;
+ private static ObjectMapper objectMapper = new ObjectMapper();
+
+
+ @Inject
+ public I18nServlet(PluginLoader pluginLoader, CacheManager cacheManager) {
+ this.classLoader = pluginLoader.getUberClassLoader();
+ this.cache = cacheManager.getCache(CACHE_NAME);
+ }
+
+ @Subscribe(async = false)
+ public void handleRestartEvent(RestartEvent event) {
+ log.debug("Clear cache on restart event with reason {}", event.getReason());
+ cache.clear();
+ }
+
+ private JsonNode getCollectedJson(String path,
+ Function> jsonFileProvider,
+ BiConsumer createdJsonFileConsumer) {
+ return Optional.ofNullable(jsonFileProvider.apply(path)
+ .orElseGet(() -> {
+ Optional createdFile = collectJsonFile(path);
+ createdFile.ifPresent(map -> createdJsonFileConsumer.accept(path, map));
+ return createdFile.orElse(null);
+ }
+ )).orElseThrow(() -> NotFoundException.notFound("jsonprovider", path).build());
+ }
+
+ @VisibleForTesting
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse response) {
+ try (PrintWriter out = response.getWriter()) {
+ response.setContentType("application/json");
+ String path = req.getServletPath();
+ Function> jsonFileProvider = usedPath -> Optional.empty();
+ BiConsumer createdJsonFileConsumer = (usedPath, jsonNode) -> log.debug("A json File is created from the path {}", usedPath);
+ if (isProductionStage()) {
+ log.debug("In Production Stage get the plugin translations from the cache");
+ jsonFileProvider = usedPath -> Optional.ofNullable(
+ cache.get(usedPath));
+ createdJsonFileConsumer = createdJsonFileConsumer
+ .andThen((usedPath, jsonNode) -> log.debug("Put the created json File in the cache with the key {}", usedPath))
+ .andThen(cache::put);
+ }
+ objectMapper.writeValue(out, getCollectedJson(path, jsonFileProvider, createdJsonFileConsumer));
+ } catch (IOException e) {
+ log.error("Error on getting the translation of the plugins", e);
+ response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ } catch (NotFoundException e) {
+ log.error("Plugin translations are not found", e);
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ }
+ }
+
+ @VisibleForTesting
+ protected boolean isProductionStage() {
+ return SCMContext.getContext().getStage() == Stage.PRODUCTION;
+ }
+
+ /**
+ * Return a collected Json File as JsonNode from the given path from all plugins in the class path
+ *
+ * @param path the searched resource path
+ * @return a collected Json File as JsonNode from the given path from all plugins in the class path
+ */
+ @VisibleForTesting
+ protected Optional collectJsonFile(String path) {
+ log.debug("Collect plugin translations from path {} for every plugin", path);
+ JsonNode mergedJsonNode = null;
+ try {
+ Enumeration resources = classLoader.getResources(path.replaceFirst("/", ""));
+ while (resources.hasMoreElements()) {
+ URL url = resources.nextElement();
+ JsonNode jsonNode = objectMapper.readTree(url);
+ if (mergedJsonNode != null) {
+ merge(mergedJsonNode, jsonNode);
+ } else {
+ mergedJsonNode = jsonNode;
+ }
+ }
+ } catch (IOException e) {
+ log.error("Error on loading sources from {}", path, e);
+ return Optional.empty();
+ }
+ return Optional.ofNullable(mergedJsonNode);
+ }
+
+
+ /**
+ * Merge the updateNode into the mainNode and return it.
+ *
+ * This is not a deep merge.
+ *
+ * @param mainNode the main node
+ * @param updateNode the update node
+ * @return the merged mainNode
+ */
+ @VisibleForTesting
+ protected JsonNode merge(JsonNode mainNode, JsonNode updateNode) {
+ Iterator fieldNames = updateNode.fieldNames();
+
+ while (fieldNames.hasNext()) {
+
+ String fieldName = fieldNames.next();
+ JsonNode jsonNode = mainNode.get(fieldName);
+
+ if (jsonNode != null) {
+ mergeNode(updateNode, fieldName, jsonNode);
+ } else {
+ mergeField(mainNode, updateNode, fieldName);
+ }
+ }
+ return mainNode;
+ }
+
+ private void mergeField(JsonNode mainNode, JsonNode updateNode, String fieldName) {
+ if (mainNode instanceof ObjectNode) {
+ JsonNode value = updateNode.get(fieldName);
+ if (value.isNull()) {
+ return;
+ }
+ if (value.isIntegralNumber() && value.toString().equals("0")) {
+ return;
+ }
+ if (value.isFloatingPointNumber() && value.toString().equals("0.0")) {
+ return;
+ }
+ ((ObjectNode) mainNode).set(fieldName, value);
+ }
+ }
+
+ private void mergeNode(JsonNode updateNode, String fieldName, JsonNode jsonNode) {
+ if (jsonNode.isObject()) {
+ merge(jsonNode, updateNode.get(fieldName));
+ } else if (jsonNode.isArray()) {
+ for (int i = 0; i < jsonNode.size(); i++) {
+ merge(jsonNode.get(i), updateNode.get(fieldName).get(i));
+ }
+ }
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapperTest.java
similarity index 64%
rename from scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapperTest.java
rename to scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapperTest.java
index 528418a187..b0f0d00708 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapperTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapperTest.java
@@ -8,34 +8,26 @@ import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
-import org.mockito.Mock;
import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.FileObject;
import sonia.scm.repository.NamespaceAndName;
import java.net.URI;
-import java.util.ArrayList;
-import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
-public class BrowserResultToBrowserResultDtoMapperTest {
+public class BrowserResultToFileObjectDtoMapperTest {
private final URI baseUri = URI.create("http://example.com/base/");
@SuppressWarnings("unused") // Is injected
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
- @Mock
- private FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper;
-
@InjectMocks
- private BrowserResultToBrowserResultDtoMapper mapper;
+ private FileObjectToFileObjectDtoMapperImpl fileObjectToFileObjectDtoMapper;
+
+ private BrowserResultToFileObjectDtoMapper mapper;
private final Subject subject = mock(Subject.class);
private final ThreadState subjectThreadState = new SubjectThreadState(subject);
@@ -47,6 +39,7 @@ public class BrowserResultToBrowserResultDtoMapperTest {
@Before
public void init() {
initMocks(this);
+ mapper = new BrowserResultToFileObjectDtoMapper(fileObjectToFileObjectDtoMapper);
subjectThreadState.bind();
ThreadContext.bind(subject);
@@ -63,9 +56,6 @@ public class BrowserResultToBrowserResultDtoMapperTest {
fileObject2.setPath("/path/object/2");
fileObject2.setDescription("description of file object 2");
fileObject2.setDirectory(true);
-
- when(fileObjectToFileObjectDtoMapper.map(any(FileObject.class), any(NamespaceAndName.class), anyString()))
- .thenReturn(new FileObjectDto());
}
@After
@@ -77,7 +67,7 @@ public class BrowserResultToBrowserResultDtoMapperTest {
public void shouldMapAttributesCorrectly() {
BrowserResult browserResult = createBrowserResult();
- BrowserResultDto dto = mapper.map(browserResult, new NamespaceAndName("foo", "bar"), "path");
+ FileObjectDto dto = mapper.map(browserResult, new NamespaceAndName("foo", "bar"));
assertEqualAttributes(browserResult, dto);
}
@@ -87,10 +77,9 @@ public class BrowserResultToBrowserResultDtoMapperTest {
BrowserResult browserResult = createBrowserResult();
NamespaceAndName namespaceAndName = new NamespaceAndName("foo", "bar");
- BrowserResultDto dto = mapper.map(browserResult, namespaceAndName, "path");
+ FileObjectDto dto = mapper.map(browserResult, namespaceAndName);
- verify(fileObjectToFileObjectDtoMapper).map(fileObject1, namespaceAndName, "Revision");
- verify(fileObjectToFileObjectDtoMapper).map(fileObject2, namespaceAndName, "Revision");
+ assertThat(dto.getEmbedded().getItemsBy("children")).hasSize(2);
}
@Test
@@ -98,28 +87,27 @@ public class BrowserResultToBrowserResultDtoMapperTest {
BrowserResult browserResult = createBrowserResult();
NamespaceAndName namespaceAndName = new NamespaceAndName("foo", "bar");
- BrowserResultDto dto = mapper.map(browserResult, namespaceAndName, "path");
+ FileObjectDto dto = mapper.map(browserResult, namespaceAndName);
assertThat(dto.getLinks().getLinkBy("self").get().getHref()).contains("path");
}
private BrowserResult createBrowserResult() {
- BrowserResult browserResult = new BrowserResult();
- browserResult.setRevision("Revision");
- browserResult.setFiles(createFileObjects());
-
- return browserResult;
+ return new BrowserResult("Revision", createFileObject());
}
- private List createFileObjects() {
- List fileObjects = new ArrayList<>();
+ private FileObject createFileObject() {
+ FileObject file = new FileObject();
+ file.setName("");
+ file.setPath("/path");
+ file.setDirectory(true);
- fileObjects.add(fileObject1);
- fileObjects.add(fileObject2);
- return fileObjects;
+ file.addChild(fileObject1);
+ file.addChild(fileObject2);
+ return file;
}
- private void assertEqualAttributes(BrowserResult browserResult, BrowserResultDto dto) {
+ private void assertEqualAttributes(BrowserResult browserResult, FileObjectDto dto) {
assertThat(dto.getRevision()).isEqualTo(browserResult.getRevision());
}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java
index 0544bf6a0d..74ff49dbb7 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java
@@ -173,6 +173,30 @@ public class ResourceLinksTest {
assertEquals(BASE_URL + ConfigResource.CONFIG_PATH_V2, url);
}
+ @Test
+ public void shouldHandleSpacesInPaths() {
+ String url = resourceLinks.source().content("space", "name", "rev", "name with spaces");
+ assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/name/content/rev/name%20with%20spaces", url);
+ }
+
+ @Test
+ public void shouldHandleBackslashInPaths() {
+ String url = resourceLinks.source().content("space", "name", "rev", "name_with_\\");
+ assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/name/content/rev/name_with_%5C", url);
+ }
+
+ @Test
+ public void shouldHandleNewLineInPaths() {
+ String url = resourceLinks.source().content("space", "name", "rev", "name_with\nnew_line");
+ assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/name/content/rev/name_with%0Anew_line", url);
+ }
+
+ @Test
+ public void shouldKeepSlashesInInPaths() {
+ String url = resourceLinks.source().content("space", "name", "rev", "some/dir/somewhere");
+ assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/name/content/rev/some/dir/somewhere", url);
+ }
+
@Before
public void initUriInfo() {
initMocks(this);
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java
index 311bff194a..542c0d017f 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java
@@ -21,12 +21,8 @@ import sonia.scm.repository.api.RepositoryServiceFactory;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
-import java.util.Arrays;
-import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher;
@@ -45,24 +41,19 @@ public class SourceRootResourceTest extends RepositoryTestBase {
@Mock
private BrowseCommandBuilder browseCommandBuilder;
- @Mock
- private FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper;
-
@InjectMocks
- private BrowserResultToBrowserResultDtoMapper browserResultToBrowserResultDtoMapper;
+ private FileObjectToFileObjectDtoMapperImpl fileObjectToFileObjectDtoMapper;
+
+ private BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper;
@Before
public void prepareEnvironment() throws Exception {
+ browserResultToFileObjectDtoMapper = new BrowserResultToFileObjectDtoMapper(fileObjectToFileObjectDtoMapper);
when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(service);
when(service.getBrowseCommand()).thenReturn(browseCommandBuilder);
- FileObjectDto dto = new FileObjectDto();
- dto.setName("name");
- dto.setLength(1024);
-
- when(fileObjectToFileObjectDtoMapper.map(any(FileObject.class), any(NamespaceAndName.class), anyString())).thenReturn(dto);
- SourceRootResource sourceRootResource = new SourceRootResource(serviceFactory, browserResultToBrowserResultDtoMapper);
+ SourceRootResource sourceRootResource = new SourceRootResource(serviceFactory, browserResultToFileObjectDtoMapper);
super.sourceRootResource = Providers.of(sourceRootResource);
dispatcher = createDispatcher(getRepositoryRootResource());
}
@@ -76,8 +67,9 @@ public class SourceRootResourceTest extends RepositoryTestBase {
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(200);
+ System.out.println(response.getContentAsString());
assertThat(response.getContentAsString()).contains("\"revision\":\"revision\"");
- assertThat(response.getContentAsString()).contains("\"files\":");
+ assertThat(response.getContentAsString()).contains("\"children\":");
}
@Test
@@ -92,12 +84,10 @@ public class SourceRootResourceTest extends RepositoryTestBase {
@Test
public void shouldGetResultForSingleFile() throws URISyntaxException, IOException {
- BrowserResult browserResult = new BrowserResult();
- browserResult.setRevision("revision");
FileObject fileObject = new FileObject();
fileObject.setName("File Object!");
-
- browserResult.setFiles(Arrays.asList(fileObject));
+ fileObject.setPath("/");
+ BrowserResult browserResult = new BrowserResult("revision", fileObject);
when(browseCommandBuilder.getBrowserResult()).thenReturn(browserResult);
MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources/revision/fileabc");
@@ -120,10 +110,15 @@ public class SourceRootResourceTest extends RepositoryTestBase {
}
private BrowserResult createBrowserResult() {
- return new BrowserResult("revision", "tag", "branch", createFileObjects());
+ return new BrowserResult("revision", createFileObject());
}
- private List createFileObjects() {
+ private FileObject createFileObject() {
+ FileObject parent = new FileObject();
+ parent.setName("bar");
+ parent.setPath("/foo/bar");
+ parent.setDirectory(true);
+
FileObject fileObject1 = new FileObject();
fileObject1.setName("FO 1");
fileObject1.setDirectory(false);
@@ -131,6 +126,7 @@ public class SourceRootResourceTest extends RepositoryTestBase {
fileObject1.setPath("/foo/bar/fo1");
fileObject1.setLength(1024L);
fileObject1.setLastModified(0L);
+ parent.addChild(fileObject1);
FileObject fileObject2 = new FileObject();
fileObject2.setName("FO 2");
@@ -139,7 +135,8 @@ public class SourceRootResourceTest extends RepositoryTestBase {
fileObject2.setPath("/foo/bar/fo2");
fileObject2.setLength(4096L);
fileObject2.setLastModified(1234L);
+ parent.addChild(fileObject2);
- return Arrays.asList(fileObject1, fileObject2);
+ return parent;
}
}
diff --git a/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java b/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java
new file mode 100644
index 0000000000..a912f738e2
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java
@@ -0,0 +1,255 @@
+package sonia.scm.web.i18n;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.sdorra.shiro.ShiroRule;
+import com.github.sdorra.shiro.SubjectAware;
+import com.google.common.base.Charsets;
+import com.google.common.io.Files;
+import org.apache.commons.lang3.StringUtils;
+import org.assertj.core.util.Lists;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockSettings;
+import org.mockito.internal.creation.MockSettingsImpl;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.junit.MockitoJUnitRunner;
+import sonia.scm.boot.RestartEvent;
+import sonia.scm.cache.Cache;
+import sonia.scm.cache.CacheManager;
+import sonia.scm.event.ScmEventBus;
+import sonia.scm.plugin.PluginLoader;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+@RunWith(MockitoJUnitRunner.Silent.class)
+@SubjectAware(configuration = "classpath:sonia/scm/shiro-001.ini")
+public class I18nServletTest {
+
+ @Rule
+ public ShiroRule shiro = new ShiroRule();
+
+ private static final String GIT_PLUGIN_JSON = json(
+ "{",
+ "'scm-git-plugin': {",
+ "'information': {",
+ "'clone' : 'Clone',",
+ "'create' : 'Create',",
+ "'replace' : 'Push'",
+ "}",
+ "}",
+ "}"
+ );
+ private static final String HG_PLUGIN_JSON = json(
+ "{",
+ "'scm-hg-plugin': {",
+ "'information': {",
+ "'clone' : 'Clone',",
+ "'create' : 'Create',",
+ "'replace' : 'Push'",
+ "}",
+ "}",
+ "}"
+ );
+ private static String SVN_PLUGIN_JSON = json(
+ "{",
+ "'scm-svn-plugin': {",
+ "'information': {",
+ "'checkout' : 'Checkout'",
+ "}",
+ "}",
+ "}"
+ );
+
+ private static String json(String... parts) {
+ return String.join("\n", parts ).replaceAll("'", "\"");
+ }
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Mock
+ PluginLoader pluginLoader;
+
+ @Mock
+ CacheManager cacheManager;
+
+ @Mock
+ ClassLoader classLoader;
+
+ I18nServlet servlet;
+
+ @Mock
+ private Cache cache;
+ private Enumeration resources;
+
+ @Before
+ @SuppressWarnings("unchecked")
+ public void init() throws IOException {
+ resources = Collections.enumeration(Lists.newArrayList(
+ createFileFromString(SVN_PLUGIN_JSON).toURL(),
+ createFileFromString(GIT_PLUGIN_JSON).toURL(),
+ createFileFromString(HG_PLUGIN_JSON).toURL()
+ ));
+ when(pluginLoader.getUberClassLoader()).thenReturn(classLoader);
+ when(cacheManager.getCache(I18nServlet.CACHE_NAME)).thenReturn(cache);
+ MockSettings settings = new MockSettingsImpl<>();
+ settings.useConstructor(pluginLoader, cacheManager);
+ settings.defaultAnswer(InvocationOnMock::callRealMethod);
+ servlet = mock(I18nServlet.class, settings);
+ }
+
+ @Test
+ public void shouldCleanCacheOnRestartEvent() {
+ ScmEventBus.getInstance().register(servlet);
+
+ ScmEventBus.getInstance().post(new RestartEvent(I18nServlet.class, "Restart to reload the plugin resources"));
+
+ verify(cache).clear();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void shouldFailWith404OnMissingResources() throws IOException {
+ String path = "/locales/de/plugins.json";
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ PrintWriter writer = mock(PrintWriter.class);
+ when(response.getWriter()).thenReturn(writer);
+ when(request.getServletPath()).thenReturn(path);
+ when(classLoader.getResources("locales/de/plugins.json")).thenThrow(IOException.class);
+
+ servlet.doGet(request, response);
+
+ verify(response).setStatus(404);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void shouldFailWith500OnIOException() throws IOException {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ doThrow(IOException.class).when(response).getWriter();
+
+ servlet.doGet(request, response);
+
+ verify(response).setStatus(500);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void inDevelopmentStageShouldNotUseCache() throws IOException {
+ String path = "/locales/de/plugins.json";
+ when(servlet.isProductionStage()).thenReturn(false);
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ File file = temporaryFolder.newFile();
+ PrintWriter writer = new PrintWriter(new FileOutputStream(file));
+ when(response.getWriter()).thenReturn(writer);
+ when(request.getServletPath()).thenReturn(path);
+ when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
+
+ servlet.doGet(request, response);
+
+ String json = Files.readLines(file, Charset.defaultCharset()).get(0);
+ assertJson(json);
+ verify(cache, never()).get(any());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void inProductionStageShouldUseCache() throws IOException {
+ String path = "/locales/de/plugins.json";
+ when(servlet.isProductionStage()).thenReturn(true);
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ File file = temporaryFolder.newFile();
+ PrintWriter writer = new PrintWriter(new FileOutputStream(file));
+ when(response.getWriter()).thenReturn(writer);
+ when(request.getServletPath()).thenReturn(path);
+ when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
+
+ servlet.doGet(request, response);
+
+ String json = Files.readLines(file, Charset.defaultCharset()).get(0);
+ assertJson(json);
+ verify(cache).get(path);
+ verify(cache).put(eq(path), any());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void inProductionStageShouldGetFromCache() throws IOException {
+ String path = "/locales/de/plugins.json";
+ when(servlet.isProductionStage()).thenReturn(true);
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ File file = temporaryFolder.newFile();
+ PrintWriter writer = new PrintWriter(new FileOutputStream(file));
+ when(response.getWriter()).thenReturn(writer);
+ when(request.getServletPath()).thenReturn(path);
+ when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
+ ObjectMapper objectMapper = new ObjectMapper();
+ JsonNode node = objectMapper.readTree(GIT_PLUGIN_JSON);
+ node = servlet.merge(node, objectMapper.readTree(HG_PLUGIN_JSON));
+ node = servlet.merge(node, objectMapper.readTree(SVN_PLUGIN_JSON));
+ when(cache.get(path)).thenReturn(node);
+
+ servlet.doGet(request, response);
+
+ String json = Files.readLines(file, Charset.defaultCharset()).get(0);
+ verify(servlet, never()).collectJsonFile(path);
+ verify(cache, never()).put(eq(path), any());
+ verify(cache).get(path);
+ assertJson(json);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void shouldCollectJsonFile() throws IOException {
+ String path = "locales/de/plugins.json";
+ when(classLoader.getResources(path)).thenReturn(resources);
+
+ Optional jsonNodeOptional = servlet.collectJsonFile("/" + path);
+
+ assertJson(jsonNodeOptional.orElse(null));
+ }
+
+ public void assertJson(JsonNode actual) throws IOException {
+ assertJson(actual.toString());
+ }
+
+ public void assertJson(String actual) throws IOException {
+ assertThat(actual)
+ .isNotEmpty()
+ .contains(StringUtils.deleteWhitespace(GIT_PLUGIN_JSON.substring(1, GIT_PLUGIN_JSON.length() - 1)))
+ .contains(StringUtils.deleteWhitespace(HG_PLUGIN_JSON.substring(1, HG_PLUGIN_JSON.length() - 1)))
+ .contains(StringUtils.deleteWhitespace(SVN_PLUGIN_JSON.substring(1, SVN_PLUGIN_JSON.length() - 1)));
+ }
+
+ public File createFileFromString(String json) throws IOException {
+ File file = temporaryFolder.newFile();
+ Files.write(json.getBytes(Charsets.UTF_8), file);
+ return file;
+ }
+
+}