This commit is contained in:
Mohamed Karray
2018-10-26 16:38:28 +02:00
95 changed files with 3785 additions and 1338 deletions

View File

@@ -1,19 +1,19 @@
/** /**
* Copyright (c) 2010, Sebastian Sdorra * Copyright (c) 2010, Sebastian Sdorra
* All rights reserved. * All rights reserved.
* * <p>
* Redistribution and use in source and binary forms, with or without * Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met: * modification, are permitted provided that the following conditions are met:
* * <p>
* 1. Redistributions of source code must retain the above copyright notice, * 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, * 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation * this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution. * and/or other materials provided with the distribution.
* 3. Neither the name of SCM-Manager; nor the names of its * 3. Neither the name of SCM-Manager; nor the names of its
* contributors may be used to endorse or promote products derived from this * contributors may be used to endorse or promote products derived from this
* software without specific prior written permission. * software without specific prior written permission.
* * <p>
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * 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 * 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 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
* * <p>
* http://bitbucket.org/sdorra/scm-manager * http://bitbucket.org/sdorra/scm-manager
*
*/ */
package sonia.scm.repository; package sonia.scm.repository;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
@@ -40,12 +38,8 @@ import com.google.common.base.Objects;
import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType; 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 javax.xml.bind.annotation.XmlRootElement;
import java.io.Serializable; import java.io.Serializable;
import java.util.Iterator;
import java.util.List;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
@@ -56,224 +50,56 @@ import java.util.List;
*/ */
@XmlAccessorType(XmlAccessType.FIELD) @XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "browser-result") @XmlRootElement(name = "browser-result")
public class BrowserResult implements Iterable<FileObject>, Serializable public class BrowserResult implements Serializable {
{
/** Field description */ private String revision;
private static final long serialVersionUID = 2818662048045182761L; private FileObject file;
//~--- constructors --------------------------------------------------------- public BrowserResult() {
/**
* Constructs ...
*
*/
public BrowserResult() {}
/**
* Constructs ...
*
*
* @param revision
* @param tag
* @param branch
* @param files
*/
public BrowserResult(String revision, String tag, String branch,
List<FileObject> files)
{
this.revision = revision;
this.tag = tag;
this.branch = branch;
this.files = files;
} }
//~--- 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 @Override
public boolean equals(Object obj) public boolean equals(Object obj) {
{ if (obj == null) {
if (obj == null)
{
return false; return false;
} }
if (getClass() != obj.getClass()) if (getClass() != obj.getClass()) {
{
return false; return false;
} }
final BrowserResult other = (BrowserResult) obj; final BrowserResult other = (BrowserResult) obj;
return Objects.equal(revision, other.revision) return Objects.equal(revision, other.revision)
&& Objects.equal(tag, other.tag) && Objects.equal(file, other.file);
&& Objects.equal(branch, other.branch)
&& Objects.equal(files, other.files);
} }
/**
* {@inheritDoc}
*
*
* @return
*/
@Override @Override
public int hashCode() public int hashCode() {
{ return Objects.hashCode(revision, file);
return Objects.hashCode(revision, tag, branch, files);
} }
/**
* Method description
*
*
* @return
*/
@Override @Override
public Iterator<FileObject> iterator() public String toString() {
{
Iterator<FileObject> it = null;
if (files != null)
{
it = files.iterator();
}
return it;
}
/**
* {@inheritDoc}
*
*
* @return
*/
@Override
public String toString()
{
//J-
return MoreObjects.toStringHelper(this) return MoreObjects.toStringHelper(this)
.add("revision", revision) .add("revision", revision)
.add("tag", tag) .add("files", file)
.add("branch", branch) .toString();
.add("files", files)
.toString();
//J+
} }
//~--- get methods ----------------------------------------------------------
/**
* Method description
*
*
* @return
*/
public String getBranch()
{
return branch;
}
/**
* Method description
*
*
* @return
*/
public List<FileObject> 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<FileObject> 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<FileObject> files;
/** Field description */
private String revision;
/** Field description */
private String tag;
} }

View File

@@ -33,10 +33,9 @@
package sonia.scm.repository; package sonia.scm.repository;
//~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.MoreObjects; import com.google.common.base.MoreObjects;
import com.google.common.base.Objects; import com.google.common.base.Objects;
import com.google.common.base.Strings;
import sonia.scm.LastModifiedAware; import sonia.scm.LastModifiedAware;
import javax.xml.bind.annotation.XmlAccessType; 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.XmlElement;
import javax.xml.bind.annotation.XmlRootElement; import javax.xml.bind.annotation.XmlRootElement;
import java.io.Serializable; 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. * The FileObject represents a file or a directory in a repository.
@@ -181,6 +183,22 @@ public class FileObject implements LastModifiedAware, Serializable
return path; 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 * Return sub repository informations or null if the file is not
* sub repository. * sub repository.
@@ -284,6 +302,22 @@ public class FileObject implements LastModifiedAware, Serializable
this.subRepository = subRepository; this.subRepository = subRepository;
} }
public Collection<FileObject> getChildren() {
return unmodifiableCollection(children);
}
public void setChildren(List<FileObject> children) {
this.children = new ArrayList<>(children);
}
public void addChild(FileObject child) {
this.children.add(child);
}
public boolean hasChildren() {
return !children.isEmpty();
}
//~--- fields --------------------------------------------------------------- //~--- fields ---------------------------------------------------------------
/** file description */ /** file description */
@@ -307,4 +341,6 @@ public class FileObject implements LastModifiedAware, Serializable
/** sub repository informations */ /** sub repository informations */
@XmlElement(name = "subrepository") @XmlElement(name = "subrepository")
private SubRepository subRepository; private SubRepository subRepository;
private Collection<FileObject> children = new ArrayList<>();
} }

View File

@@ -161,11 +161,21 @@ public class PreProcessorUtil
{ {
if (logger.isTraceEnabled()) if (logger.isTraceEnabled())
{ {
logger.trace("prepare browser result of repository {} for return", logger.trace("prepare browser result of repository {} for return", repository.getName());
repository.getName());
} }
handlePreProcessForIterable(repository, result,fileObjectPreProcessorFactorySet, fileObjectPreProcessorSet); PreProcessorHandler<FileObject> handler = new PreProcessorHandler<>(fileObjectPreProcessorFactorySet, fileObjectPreProcessorSet, repository);
handlePreProcessorForFileObject(handler, result.getFile());
}
private void handlePreProcessorForFileObject(PreProcessorHandler<FileObject> handler, FileObject fileObject) {
if (fileObject.isDirectory()) {
for (FileObject child : fileObject.getChildren()) {
handlePreProcessorForFileObject(handler, child);
}
}
handler.callPreProcessorFactories(fileObject);
handler.callPreProcessors(fileObject);
} }
/** /**

View File

@@ -38,11 +38,11 @@ package sonia.scm.repository.api;
import com.google.common.base.Objects; import com.google.common.base.Objects;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.NotFoundException;
import sonia.scm.cache.Cache; import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager; import sonia.scm.cache.CacheManager;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.FileObject; import sonia.scm.repository.FileObject;
import sonia.scm.repository.FileObjectNameComparator;
import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.PreProcessorUtil;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryCacheKey; import sonia.scm.repository.RepositoryCacheKey;
@@ -52,8 +52,6 @@ import sonia.scm.repository.spi.BrowseCommandRequest;
import java.io.IOException; import java.io.IOException;
import java.io.Serializable; import java.io.Serializable;
import java.util.Collections;
import java.util.List;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
@@ -138,7 +136,7 @@ public final class BrowseCommandBuilder
* *
* @throws IOException * @throws IOException
*/ */
public BrowserResult getBrowserResult() throws IOException, RevisionNotFoundException { public BrowserResult getBrowserResult() throws IOException, NotFoundException {
BrowserResult result = null; BrowserResult result = null;
if (disableCache) if (disableCache)
@@ -180,14 +178,6 @@ public final class BrowseCommandBuilder
if (!disablePreProcessors && (result != null)) if (!disablePreProcessors && (result != null))
{ {
preProcessorUtil.prepareForReturn(repository, result); preProcessorUtil.prepareForReturn(repository, result);
List<FileObject> fileObjects = result.getFiles();
if (fileObjects != null)
{
Collections.sort(fileObjects, FileObjectNameComparator.instance);
result.setFiles(fileObjects);
}
} }
return result; return result;

View File

@@ -35,8 +35,8 @@ package sonia.scm.repository.spi;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
import sonia.scm.NotFoundException;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.RevisionNotFoundException;
import java.io.IOException; import java.io.IOException;
@@ -60,4 +60,5 @@ public interface BrowseCommand
* *
* @throws IOException * @throws IOException
*/ */
BrowserResult getBrowserResult(BrowseCommandRequest request) throws IOException, RevisionNotFoundException;} BrowserResult getBrowserResult(BrowseCommandRequest request) throws IOException, NotFoundException;
}

View File

@@ -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;
}
}

View File

@@ -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");
}
}

View File

@@ -200,7 +200,7 @@ public class RepositoryAccessITCase {
.then() .then()
.statusCode(HttpStatus.SC_OK) .statusCode(HttpStatus.SC_OK)
.extract() .extract()
.path("_embedded.files.find{it.name=='a.txt'}._links.self.href"); .path("_embedded.children.find{it.name=='a.txt'}._links.self.href");
given() given()
.when() .when()
@@ -215,7 +215,7 @@ public class RepositoryAccessITCase {
.then() .then()
.statusCode(HttpStatus.SC_OK) .statusCode(HttpStatus.SC_OK)
.extract() .extract()
.path("_embedded.files.find{it.name=='subfolder'}._links.self.href"); .path("_embedded.children.find{it.name=='subfolder'}._links.self.href");
String selfOfSubfolderUrl = given() String selfOfSubfolderUrl = given()
.when() .when()
.get(subfolderSourceUrl) .get(subfolderSourceUrl)
@@ -230,7 +230,7 @@ public class RepositoryAccessITCase {
.then() .then()
.statusCode(HttpStatus.SC_OK) .statusCode(HttpStatus.SC_OK)
.extract() .extract()
.path("_embedded.files[0]._links.self.href"); .path("_embedded.children[0]._links.self.href");
given() given()
.when() .when()
.get(subfolderContentUrl) .get(subfolderContentUrl)

View File

@@ -2,11 +2,13 @@ package sonia.scm.it.utils;
import io.restassured.RestAssured; import io.restassured.RestAssured;
import io.restassured.response.Response; import io.restassured.response.Response;
import org.junit.Assert;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.user.User; import sonia.scm.user.User;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
import java.net.ConnectException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -52,7 +54,12 @@ public class ScmRequests {
setUsername(username); setUsername(username);
setPassword(password); setPassword(password);
return new ChangePasswordResponse<>(applyPUTRequest(RestUtil.REST_BASE_URL.resolve("users/"+userPathParam+"/password").toString(), VndMediaType.PASSWORD_OVERWRITE, TestData.createPasswordChangeJson(password,newPassword)), null); 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 * @return the response of the GET request using the given link
*/ */
private Response applyGETRequestFromLinkWithParams(Response response, String linkPropertyName, String params) { private Response applyGETRequestFromLinkWithParams(Response response, String linkPropertyName, String params) {
return applyGETRequestWithQueryParams(response String url = response
.then() .then()
.extract() .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) { private Response applyGETRequestWithQueryParams(String url, String params) {
LOG.info("GET {}", url); LOG.info("GET {}", url);
if (username == null || password == null){
return RestAssured.given()
.when()
.get(url + params);
}
return RestAssured.given() return RestAssured.given()
.auth().preemptive().basic(username, password) .auth().preemptive().basic(username, password)
.when() .when()
@@ -249,11 +263,11 @@ public class ScmRequests {
} }
public ChangesetsResponse<SourcesResponse> requestFileHistory(String fileName) { public ChangesetsResponse<SourcesResponse> 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<SourcesResponse> requestSelf(String fileName) { public SourcesResponse<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);
} }
} }

View File

@@ -35,9 +35,9 @@ package sonia.scm.repository.spi;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.Strings;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import com.google.common.collect.Maps; import com.google.common.collect.Maps;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader; import org.eclipse.jgit.lib.ObjectLoader;
@@ -50,6 +50,7 @@ import org.eclipse.jgit.treewalk.filter.PathFilter;
import org.eclipse.jgit.treewalk.filter.TreeFilter; import org.eclipse.jgit.treewalk.filter.TreeFilter;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.NotFoundException;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.FileObject; import sonia.scm.repository.FileObject;
import sonia.scm.repository.GitSubModuleParser; import sonia.scm.repository.GitSubModuleParser;
@@ -103,10 +104,11 @@ public class GitBrowseCommand extends AbstractGitCommand
@Override @Override
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public BrowserResult getBrowserResult(BrowseCommandRequest request) public BrowserResult getBrowserResult(BrowseCommandRequest request)
throws IOException, RevisionNotFoundException { throws IOException, NotFoundException {
logger.debug("try to create browse result for {}", request); logger.debug("try to create browse result for {}", request);
BrowserResult result; BrowserResult result;
org.eclipse.jgit.lib.Repository repo = open(); org.eclipse.jgit.lib.Repository repo = open();
ObjectId revId; ObjectId revId;
@@ -121,7 +123,7 @@ public class GitBrowseCommand extends AbstractGitCommand
if (revId != null) if (revId != null)
{ {
result = getResult(repo, request, revId); result = new BrowserResult(revId.getName(), getEntry(repo, request, revId));
} }
else else
{ {
@@ -134,8 +136,7 @@ public class GitBrowseCommand extends AbstractGitCommand
logger.warn("coul not find head of repository, empty?"); logger.warn("coul not find head of repository, empty?");
} }
result = new BrowserResult(Constants.HEAD, null, null, result = new BrowserResult(Constants.HEAD, createEmtpyRoot());
Collections.EMPTY_LIST);
} }
return result; return result;
@@ -143,6 +144,14 @@ public class GitBrowseCommand extends AbstractGitCommand
//~--- methods -------------------------------------------------------------- //~--- methods --------------------------------------------------------------
private FileObject createEmtpyRoot() {
FileObject fileObject = new FileObject();
fileObject.setName("");
fileObject.setPath("");
fileObject.setDirectory(true);
return fileObject;
}
/** /**
* Method description * Method description
* *
@@ -158,68 +167,52 @@ public class GitBrowseCommand extends AbstractGitCommand
private FileObject createFileObject(org.eclipse.jgit.lib.Repository repo, private FileObject createFileObject(org.eclipse.jgit.lib.Repository repo,
BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk)
throws IOException, RevisionNotFoundException { throws IOException, RevisionNotFoundException {
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.setDirectory(loader.getType() == Constants.OBJ_TREE);
file.setPath(path); file.setLength(loader.getSize());
SubRepository sub = null; // don't show message and date for directories to improve performance
if (!file.isDirectory() &&!request.isDisableLastCommit())
if (!request.isDisableSubRepositoryDetection())
{ {
sub = getSubRepository(repo, revId, path); logger.trace("fetch last commit for {} at {}", path, revId.getName());
} RevCommit commit = getLatestCommit(repo, revId, path);
if (sub != null) if (commit != 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())
{ {
logger.trace("fetch last commit for {} at {}", path, revId.getName()); file.setLastModified(GitUtil.getCommitTime(commit));
file.setDescription(commit.getShortMessage());
RevCommit commit = getLatestCommit(repo, revId, path); }
else if (logger.isWarnEnabled())
if (commit != null) {
{ logger.warn("could not find latest commit for {} on {}", path,
file.setLastModified(GitUtil.getCommitTime(commit)); revId);
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; return file;
} }
@@ -265,22 +258,19 @@ public class GitBrowseCommand extends AbstractGitCommand
return result; return result;
} }
private BrowserResult getResult(org.eclipse.jgit.lib.Repository repo, private FileObject getEntry(org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId) throws IOException, NotFoundException {
BrowseCommandRequest request, ObjectId revId)
throws IOException, RevisionNotFoundException {
BrowserResult result = null;
RevWalk revWalk = null; RevWalk revWalk = null;
TreeWalk treeWalk = null; TreeWalk treeWalk = null;
try FileObject result;
{
if (logger.isDebugEnabled()) try {
{ logger.debug("load repository browser for revision {}", revId.name());
logger.debug("load repository browser for revision {}", revId.name());
}
treeWalk = new TreeWalk(repo); treeWalk = new TreeWalk(repo);
treeWalk.setRecursive(request.isRecursive()); if (!isRootRequest(request)) {
treeWalk.setFilter(PathFilter.create(request.getPath()));
}
revWalk = new RevWalk(repo); revWalk = new RevWalk(repo);
RevTree tree = revWalk.parseTree(revId); RevTree tree = revWalk.parseTree(revId);
@@ -291,65 +281,20 @@ public class GitBrowseCommand extends AbstractGitCommand
} }
else else
{ {
logger.error("could not find tree for {}", revId.name()); throw new IllegalStateException("could not find tree for " + revId.name());
} }
result = new BrowserResult(); if (isRootRequest(request)) {
result = createEmtpyRoot();
List<FileObject> files = Lists.newArrayList(); findChildren(result, repo, request, revId, treeWalk);
} else {
String path = request.getPath(); result = findFirstMatch(repo, request, revId, treeWalk);
if ( result.isDirectory() ) {
if (Util.isEmpty(path)) treeWalk.enterSubtree();
{ findChildren(result, repo, request, revId, treeWalk);
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();
}
}
} }
} }
result.setFiles(files);
result.setRevision(revId.getName());
} }
finally finally
{ {
@@ -360,6 +305,60 @@ public class GitBrowseCommand extends AbstractGitCommand
return result; 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<FileObject> 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") @SuppressWarnings("unchecked")
private Map<String, private Map<String,
SubRepository> getSubRepositories(org.eclipse.jgit.lib.Repository repo, SubRepository> getSubRepositories(org.eclipse.jgit.lib.Repository repo,

View File

@@ -2,15 +2,17 @@
import React from "react"; import React from "react";
import { repositories } from "@scm-manager/ui-components"; import { repositories } from "@scm-manager/ui-components";
import type { Repository } from "@scm-manager/ui-types"; import type { Repository } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
type Props = { type Props = {
repository: Repository repository: Repository,
t: string => string
} }
class ProtocolInformation extends React.Component<Props> { class ProtocolInformation extends React.Component<Props> {
render() { render() {
const { repository } = this.props; const { repository, t } = this.props;
const href = repositories.getProtocolLinkByType(repository, "http"); const href = repositories.getProtocolLinkByType(repository, "http");
if (!href) { if (!href) {
return null; return null;
@@ -18,11 +20,11 @@ class ProtocolInformation extends React.Component<Props> {
return ( return (
<div> <div>
<h4>Clone the repository</h4> <h4>{t("scm-git-plugin.information.clone")}</h4>
<pre> <pre>
<code>git clone {href}</code> <code>git clone {href}</code>
</pre> </pre>
<h4>Create a new repository</h4> <h4>{t("scm-git-plugin.information.create")}</h4>
<pre> <pre>
<code> <code>
git init {repository.name} git init {repository.name}
@@ -39,7 +41,7 @@ class ProtocolInformation extends React.Component<Props> {
<br /> <br />
</code> </code>
</pre> </pre>
<h4>Push an existing repository</h4> <h4>{t("scm-git-plugin.information.replace")}</h4>
<pre> <pre>
<code> <code>
git remote add origin {href} git remote add origin {href}
@@ -54,4 +56,4 @@ class ProtocolInformation extends React.Component<Props> {
} }
export default ProtocolInformation; export default translate("plugins")(ProtocolInformation);

View File

@@ -0,0 +1,9 @@
{
"scm-git-plugin": {
"information": {
"clone" : "Repository Klonen",
"create" : "Neue Repository erstellen",
"replace" : "Eine existierende Repository aktualisieren"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"scm-git-plugin": {
"information": {
"clone" : "Clone the repository",
"create" : "Create a new repository",
"replace" : "Push an existing repository"
}
}
}

View File

@@ -6,13 +6,13 @@
* modification, are permitted provided that the following conditions are met: * modification, are permitted provided that the following conditions are met:
* *
* 1. Redistributions of source code must retain the above copyright notice, * 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, * 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation * this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution. * and/or other materials provided with the distribution.
* 3. Neither the name of SCM-Manager; nor the names of its * 3. Neither the name of SCM-Manager; nor the names of its
* contributors may be used to endorse or promote products derived from this * contributors may be used to endorse or promote products derived from this
* software without specific prior written permission. * software without specific prior written permission.
* *
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
@@ -26,152 +26,114 @@
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
* *
* http://bitbucket.org/sdorra/scm-manager * http://bitbucket.org/sdorra/scm-manager
*
*/ */
package sonia.scm.repository.spi; package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
import org.junit.Test; import org.junit.Test;
import sonia.scm.NotFoundException;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.FileObject; import sonia.scm.repository.FileObject;
import sonia.scm.repository.GitConstants; import sonia.scm.repository.GitConstants;
import sonia.scm.repository.RevisionNotFoundException;
import java.io.IOException; 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.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
//~--- JDK imports ------------------------------------------------------------
/** /**
* Unit tests for {@link GitBrowseCommand}. * Unit tests for {@link GitBrowseCommand}.
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
*/ */
public class GitBrowseCommandTest extends AbstractGitCommandTestBase public class GitBrowseCommandTest extends AbstractGitCommandTestBase {
{
/**
* Test browse command with default branch.
*/
@Test @Test
public void testDefaultBranch() throws IOException, RevisionNotFoundException { public void testGetFile() throws IOException, NotFoundException {
// without default branch, the repository head should be used BrowseCommandRequest request = new BrowseCommandRequest();
BrowserResult result = createCommand().getBrowserResult(new BrowseCommandRequest()); request.setPath("a.txt");
assertNotNull(result); BrowserResult result = createCommand().getBrowserResult(request);
FileObject fileObject = result.getFile();
List<FileObject> foList = result.getFiles(); assertEquals("a.txt", fileObject.getName());
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
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());
} }
@Test @Test
public void testBrowse() throws IOException, RevisionNotFoundException { public void testDefaultDefaultBranch() throws IOException, NotFoundException {
BrowserResult result = // without default branch, the repository head should be used
createCommand().getBrowserResult(new BrowseCommandRequest()); FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
assertNotNull(root);
assertNotNull(result);
List<FileObject> foList = result.getFiles();
Collection<FileObject> foList = root.getChildren();
assertNotNull(foList); assertNotNull(foList);
assertFalse(foList.isEmpty()); assertFalse(foList.isEmpty());
assertEquals(4, foList.size());
FileObject a = null; assertThat(foList)
FileObject c = null; .extracting("name")
.containsExactly("a.txt", "b.txt", "c", "f.txt");
}
for (FileObject f : foList) @Test
{ public void testExplicitDefaultBranch() throws IOException, NotFoundException {
if ("a.txt".equals(f.getName())) repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch");
{
a = f; FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
} assertNotNull(root);
else if ("c".equals(f.getName()))
{ Collection<FileObject> foList = root.getChildren();
c = f; assertThat(foList)
} .extracting("name")
} .containsExactly("a.txt", "c");
}
@Test
public void testBrowse() throws IOException, NotFoundException {
FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
assertNotNull(root);
Collection<FileObject> foList = root.getChildren();
FileObject a = findFile(foList, "a.txt");
FileObject c = findFile(foList, "c");
assertNotNull(a);
assertFalse(a.isDirectory()); assertFalse(a.isDirectory());
assertEquals("a.txt", a.getName()); assertEquals("a.txt", a.getName());
assertEquals("a.txt", a.getPath()); assertEquals("a.txt", a.getPath());
assertEquals("added new line for blame", a.getDescription()); assertEquals("added new line for blame", a.getDescription());
assertTrue(a.getLength() > 0); assertTrue(a.getLength() > 0);
checkDate(a.getLastModified()); checkDate(a.getLastModified());
assertNotNull(c);
assertTrue(c.isDirectory()); assertTrue(c.isDirectory());
assertEquals("c", c.getName()); assertEquals("c", c.getName());
assertEquals("c", c.getPath()); assertEquals("c", c.getPath());
} }
@Test @Test
public void testBrowseSubDirectory() throws IOException, RevisionNotFoundException { public void testBrowseSubDirectory() throws IOException, NotFoundException {
BrowseCommandRequest request = new BrowseCommandRequest(); BrowseCommandRequest request = new BrowseCommandRequest();
request.setPath("c"); request.setPath("c");
BrowserResult result = createCommand().getBrowserResult(request); FileObject root = createCommand().getBrowserResult(request).getFile();
assertNotNull(result); Collection<FileObject> foList = root.getChildren();
List<FileObject> foList = result.getFiles(); assertThat(foList).hasSize(2);
assertNotNull(foList); FileObject d = findFile(foList, "d.txt");
assertFalse(foList.isEmpty()); FileObject e = findFile(foList, "e.txt");
assertEquals(2, foList.size());
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()); assertFalse(d.isDirectory());
assertEquals("d.txt", d.getName()); assertEquals("d.txt", d.getName());
assertEquals("c/d.txt", d.getPath()); assertEquals("c/d.txt", d.getPath());
assertEquals("added file d and e in folder c", d.getDescription()); assertEquals("added file d and e in folder c", d.getDescription());
assertTrue(d.getLength() > 0); assertTrue(d.getLength() > 0);
checkDate(d.getLastModified()); checkDate(d.getLastModified());
assertNotNull(e);
assertFalse(e.isDirectory()); assertFalse(e.isDirectory());
assertEquals("e.txt", e.getName()); assertEquals("e.txt", e.getName());
assertEquals("c/e.txt", e.getPath()); assertEquals("c/e.txt", e.getPath());
@@ -181,30 +143,35 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase
} }
@Test @Test
public void testRecusive() throws IOException, RevisionNotFoundException { public void testRecursive() throws IOException, NotFoundException {
BrowseCommandRequest request = new BrowseCommandRequest(); BrowseCommandRequest request = new BrowseCommandRequest();
request.setRecursive(true); request.setRecursive(true);
BrowserResult result = createCommand().getBrowserResult(request); FileObject root = createCommand().getBrowserResult(request).getFile();
assertNotNull(result); Collection<FileObject> foList = root.getChildren();
List<FileObject> foList = result.getFiles(); assertThat(foList)
.extracting("name")
.containsExactly("a.txt", "b.txt", "c", "f.txt");
assertNotNull(foList); FileObject c = findFile(foList, "c");
assertFalse(foList.isEmpty());
assertEquals(5, foList.size()); Collection<FileObject> cChildren = c.getChildren();
assertThat(cChildren)
.extracting("name")
.containsExactly("d.txt", "e.txt");
} }
/** private FileObject findFile(Collection<FileObject> foList, String name) {
* Method description return foList.stream()
* .filter(f -> name.equals(f.getName()))
* .findFirst()
* @return .orElseThrow(() -> new AssertionError("file " + name + " not found"));
*/ }
private GitBrowseCommand createCommand()
{ private GitBrowseCommand createCommand() {
return new GitBrowseCommand(createContext(), repository); return new GitBrowseCommand(createContext(), repository);
} }
} }

View File

@@ -35,8 +35,10 @@ package sonia.scm.repository.spi;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.FileObject;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.spi.javahg.HgFileviewCommand; import sonia.scm.repository.spi.javahg.HgFileviewCommand;
@@ -45,6 +47,7 @@ import java.io.IOException;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
/** /**
* Utilizes the mercurial fileview extension in order to support mercurial repository browsing.
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
*/ */
@@ -94,16 +97,7 @@ public class HgBrowseCommand extends AbstractCommand implements BrowseCommand
cmd.disableSubRepositoryDetection(); cmd.disableSubRepositoryDetection();
} }
BrowserResult result = new BrowserResult(); FileObject file = cmd.execute();
return new BrowserResult(MoreObjects.firstNonNull(request.getRevision(), "tip"), file);
result.setFiles(cmd.execute());
if (!Strings.isNullOrEmpty(request.getRevision())) {
result.setRevision(request.getRevision());
} else {
result.setRevision("tip");
}
return result;
} }
} }

View File

@@ -50,35 +50,31 @@ import sonia.scm.repository.SubRepository;
import java.io.IOException; import java.io.IOException;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List; import java.util.List;
/** /**
* Mercurial command to list files of a repository.
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
*/ */
public class HgFileviewCommand extends AbstractCommand public class HgFileviewCommand extends AbstractCommand
{ {
/** private boolean disableLastCommit = false;
* Constructs ...
* private HgFileviewCommand(Repository repository)
*
* @param repository
*/
public HgFileviewCommand(Repository repository)
{ {
super(repository); super(repository);
} }
//~--- methods --------------------------------------------------------------
/** /**
* Method description * Create command for the given repository.
* *
* @param repository repository
* *
* @param repository * @return fileview command
*
* @return
*/ */
public static HgFileviewCommand on(Repository repository) 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 {@code this}
* @return
*/ */
public HgFileviewCommand disableLastCommit() public HgFileviewCommand disableLastCommit() {
{
disableLastCommit = true; disableLastCommit = true;
cmdAppend("-d"); cmdAppend("-d");
@@ -100,132 +94,128 @@ public class HgFileviewCommand extends AbstractCommand
} }
/** /**
* Method description * Disables sub repository detection
* *
* * @return {@code this}
* @return
*/ */
public HgFileviewCommand disableSubRepositoryDetection() public HgFileviewCommand disableSubRepositoryDetection() {
{
cmdAppend("-s"); cmdAppend("-s");
return this; 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<FileObject> execute() throws IOException public HgFileviewCommand path(String path) {
{
cmdAppend("-t");
List<FileObject> 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)
{
cmdAppend("-p", path); cmdAppend("-p", path);
return this; return this;
} }
/** /**
* Method description * Fetch file objects recursive.
* *
* *
* @return * @return {@code this}
*/ */
public HgFileviewCommand recursive() public HgFileviewCommand recursive() {
{
cmdAppend("-c"); cmdAppend("-c");
return this; return this;
} }
/** /**
* Method description * Use given revision for file view.
* *
* @param revision revision id, hash, tag or branch
* *
* @param revision * @return {@code this}
*
* @return
*/ */
public HgFileviewCommand rev(String revision) public HgFileviewCommand rev(String revision) {
{
cmdAppend("-r", revision); cmdAppend("-r", revision);
return this; return this;
} }
//~--- get methods ----------------------------------------------------------
/** /**
* Method description * Executes the mercurial command and parses the output.
* *
* * @return file object
* @return
*/
@Override
public String getCommandName()
{
return HgFileviewExtension.NAME;
}
//~--- methods --------------------------------------------------------------
/**
* Method description
*
*
* @param stream
*
* @return
* *
* @throws IOException * @throws IOException
*/ */
private FileObject readDirectory(HgInputStream stream) throws IOException public FileObject execute() throws IOException
{ {
cmdAppend("-t");
Deque<FileObject> 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(); FileObject directory = new FileObject();
String path = removeTrailingSlash(stream.textUpTo('\0')); String path = removeTrailingSlash(stream.textUpTo('\0'));
@@ -236,18 +226,7 @@ public class HgFileviewCommand extends AbstractCommand
return directory; return directory;
} }
/** private FileObject readFile(HgInputStream stream) throws IOException {
* Method description
*
*
* @param stream
*
* @return
*
* @throws IOException
*/
private FileObject readFile(HgInputStream stream) throws IOException
{
FileObject file = new FileObject(); FileObject file = new FileObject();
String path = removeTrailingSlash(stream.textUpTo('\n')); String path = removeTrailingSlash(stream.textUpTo('\n'));
@@ -259,8 +238,7 @@ public class HgFileviewCommand extends AbstractCommand
DateTime timestamp = stream.dateTimeUpTo(' '); DateTime timestamp = stream.dateTimeUpTo(' ');
String description = stream.textUpTo('\0'); String description = stream.textUpTo('\0');
if (!disableLastCommit) if (!disableLastCommit) {
{
file.setLastModified(timestamp.getDate().getTime()); file.setLastModified(timestamp.getDate().getTime());
file.setDescription(description); file.setDescription(description);
} }
@@ -268,18 +246,7 @@ public class HgFileviewCommand extends AbstractCommand
return file; return file;
} }
/** private FileObject readSubRepository(HgInputStream stream) throws IOException {
* Method description
*
*
* @param stream
*
* @return
*
* @throws IOException
*/
private FileObject readSubRepository(HgInputStream stream) throws IOException
{
FileObject directory = new FileObject(); FileObject directory = new FileObject();
String path = removeTrailingSlash(stream.textUpTo('\n')); String path = removeTrailingSlash(stream.textUpTo('\n'));
@@ -292,8 +259,7 @@ public class HgFileviewCommand extends AbstractCommand
SubRepository subRepository = new SubRepository(url); SubRepository subRepository = new SubRepository(url);
if (!Strings.isNullOrEmpty(revision)) if (!Strings.isNullOrEmpty(revision)) {
{
subRepository.setRevision(revision); subRepository.setRevision(revision);
} }
@@ -302,48 +268,33 @@ public class HgFileviewCommand extends AbstractCommand
return directory; return directory;
} }
/** private String removeTrailingSlash(String path) {
* Method description if (path.endsWith("/")) {
*
*
* @param path
*
* @return
*/
private String removeTrailingSlash(String path)
{
if (path.endsWith("/"))
{
path = path.substring(0, path.length() - 1); path = path.substring(0, path.length() - 1);
} }
return path; return path;
} }
//~--- get methods ---------------------------------------------------------- private String getNameFromPath(String path) {
/**
* Method description
*
*
* @param path
*
* @return
*/
private String getNameFromPath(String path)
{
int index = path.lastIndexOf('/'); int index = path.lastIndexOf('/');
if (index > 0) if (index > 0) {
{
path = path.substring(index + 1); path = path.substring(index + 1);
} }
return path; 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;
} }

View File

@@ -2,26 +2,28 @@
import React from "react"; import React from "react";
import { repositories } from "@scm-manager/ui-components"; import { repositories } from "@scm-manager/ui-components";
import type { Repository } from "@scm-manager/ui-types"; import type { Repository } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
type Props = { type Props = {
repository: Repository repository: Repository,
t: string => string
} }
class ProtocolInformation extends React.Component<Props> { class ProtocolInformation extends React.Component<Props> {
render() { render() {
const { repository } = this.props; const { repository, t } = this.props;
const href = repositories.getProtocolLinkByType(repository, "http"); const href = repositories.getProtocolLinkByType(repository, "http");
if (!href) { if (!href) {
return null; return null;
} }
return ( return (
<div> <div>
<h4>Clone the repository</h4> <h4>{t("scm-hg-plugin.information.clone")}</h4>
<pre> <pre>
<code>hg clone {href}</code> <code>hg clone {href}</code>
</pre> </pre>
<h4>Create a new repository</h4> <h4>{t("scm-hg-plugin.information.create")}</h4>
<pre> <pre>
<code> <code>
hg init {repository.name} hg init {repository.name}
@@ -41,7 +43,7 @@ class ProtocolInformation extends React.Component<Props> {
<br /> <br />
</code> </code>
</pre> </pre>
<h4>Push an existing repository</h4> <h4>{t("scm-hg-plugin.information.replace")}</h4>
<pre> <pre>
<code> <code>
# add the repository url as default to your .hg/hgrc e.g: # add the repository url as default to your .hg/hgrc e.g:
@@ -59,4 +61,4 @@ class ProtocolInformation extends React.Component<Props> {
} }
export default ProtocolInformation; export default translate("plugins")(ProtocolInformation);

View File

@@ -0,0 +1,9 @@
{
"scm-hg-plugin": {
"information": {
"clone" : "Repository Klonen",
"create" : "Neue Repository erstellen",
"replace" : "Eine existierende Repository aktualisieren"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"scm-hg-plugin": {
"information": {
"clone" : "Clone the repository",
"create" : "Create a new repository",
"replace" : "Push an existing repository"
}
}
}

View File

@@ -32,61 +32,129 @@
Prints date, size and last message of files. Prints date, size and last message of files.
""" """
from collections import defaultdict
from mercurial import cmdutil,util from mercurial import cmdutil,util
cmdtable = {} cmdtable = {}
command = cmdutil.command(cmdtable) command = cmdutil.command(cmdtable)
FILE_MARKER = '<files>'
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: class SubRepository:
url = None url = None
revision = None revision = None
def removeTrailingSlash(path): def collect_sub_repositories(revCtx):
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):
subrepos = {} subrepos = {}
try: try:
hgsub = revCtx.filectx('.hgsub').data().split('\n') hgsub = revCtx.filectx('.hgsub').data().split('\n')
@@ -98,7 +166,7 @@ def createSubRepositoryMap(revCtx):
subrepos[parts[0].strip()] = subrepo subrepos[parts[0].strip()] = subrepo
except Exception: except Exception:
pass pass
try: try:
hgsubstate = revCtx.filectx('.hgsubstate').data().split('\n') hgsubstate = revCtx.filectx('.hgsubstate').data().split('\n')
for line in hgsubstate: for line in hgsubstate:
@@ -109,32 +177,77 @@ def createSubRepositoryMap(revCtx):
subrepo.revision = subrev subrepo.revision = subrev
except Exception: except Exception:
pass pass
return subrepos return subrepos
def printSubRepository(ui, path, subrepository, transport): class File_Printer:
format = '%s %s %s\n'
if transport: def __init__(self, ui, repo, revCtx, disableLastCommit, transport):
format = 's%s\n%s %s\0' self.ui = ui
ui.write( format % (appendTrailingSlash(path), subrepository.revision, subrepository.url)) self.repo = repo
self.revCtx = revCtx
def printDirectory(ui, path, transport): self.disableLastCommit = disableLastCommit
format = '%s\n' self.transport = transport
if transport:
format = 'd%s\0' def print_directory(self, path):
ui.write( format % path) format = '%s/\n'
if self.transport:
def printFile(ui, repo, file, disableLastCommit, transport): format = 'd%s/\0'
date = '0 0' self.ui.write( format % path)
description = 'n/a'
if not disableLastCommit: def print_file(self, path):
linkrev = repo[file.linkrev()] file = self.revCtx[path]
date = '%d %d' % util.parsedate(linkrev.date()) date = '0 0'
description = linkrev.description() description = 'n/a'
format = '%s %i %s %s\n' if not self.disableLastCommit:
if transport: linkrev = self.repo[file.linkrev()]
format = 'f%s\n%i %s %s\0' date = '%d %d' % util.parsedate(linkrev.date())
ui.write( format % (file.path(), file.size(), date, description) ) 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', [ @command('fileview', [
('r', 'revision', 'tip', 'revision to print'), ('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'), ('t', 'transport', False, 'format the output for command server'),
]) ])
def fileview(ui, repo, **opts): def fileview(ui, repo, **opts):
files = [] revCtx = repo[opts["revision"]]
directories = [] subrepos = {}
revision = opts['revision'] if not opts["disableSubRepositoryDetection"]:
if revision == None: subrepos = collect_sub_repositories(revCtx)
revision = 'tip' printer = File_Printer(ui, repo, revCtx, opts["disableLastCommit"], opts["transport"])
revCtx = repo[revision] viewer = File_Viewer(revCtx, printer)
path = opts['path'] viewer.recursive = opts["recursive"]
if path.endswith('/'): viewer.sub_repositories = subrepos
path = path[0:-1] viewer.view(opts["path"])
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)

View File

@@ -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()

View File

@@ -33,14 +33,12 @@
package sonia.scm.repository.spi; package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
import org.junit.Test; import org.junit.Test;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.FileObject; import sonia.scm.repository.FileObject;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.Collection;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; 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.assertNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
//~--- JDK imports ------------------------------------------------------------
/** /**
* *
* @author Sebastian Sdorra * @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 @Test
public void testBrowse() throws IOException { public void testBrowse() throws IOException {
List<FileObject> foList = getRootFromTip(new BrowseCommandRequest()); Collection<FileObject> foList = getRootFromTip(new BrowseCommandRequest());
FileObject a = getFileObject(foList, "a.txt"); FileObject a = getFileObject(foList, "a.txt");
FileObject c = getFileObject(foList, "c"); FileObject c = getFileObject(foList, "c");
@@ -85,7 +90,9 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
assertNotNull(result); assertNotNull(result);
List<FileObject> foList = result.getFiles(); FileObject c = result.getFile();
assertEquals("c", c.getName());
Collection<FileObject> foList = c.getChildren();
assertNotNull(foList); assertNotNull(foList);
assertFalse(foList.isEmpty()); assertFalse(foList.isEmpty());
@@ -128,7 +135,7 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
request.setDisableLastCommit(true); request.setDisableLastCommit(true);
List<FileObject> foList = getRootFromTip(request); Collection<FileObject> foList = getRootFromTip(request);
FileObject a = getFileObject(foList, "a.txt"); FileObject a = getFileObject(foList, "a.txt");
@@ -147,11 +154,16 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
assertNotNull(result); assertNotNull(result);
List<FileObject> foList = result.getFiles(); FileObject root = result.getFile();
Collection<FileObject> foList = root.getChildren();
assertNotNull(foList); assertNotNull(foList);
assertFalse(foList.isEmpty()); 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 ---------------------------------------------------------- //~--- get methods ----------------------------------------------------------
@@ -165,32 +177,22 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
* *
* @return * @return
*/ */
private FileObject getFileObject(List<FileObject> foList, String name) private FileObject getFileObject(Collection<FileObject> foList, String name)
{ {
FileObject a = null; return foList.stream()
.filter(f -> name.equals(f.getName()))
for (FileObject f : foList) .findFirst()
{ .orElseThrow(() -> new AssertionError("file " + name + " not found"));
if (name.equals(f.getName()))
{
a = f;
break;
}
}
assertNotNull(a);
return a;
} }
private List<FileObject> getRootFromTip(BrowseCommandRequest request) throws IOException { private Collection<FileObject> getRootFromTip(BrowseCommandRequest request) throws IOException {
BrowserResult result = new HgBrowseCommand(cmdContext, BrowserResult result = new HgBrowseCommand(cmdContext,
repository).getBrowserResult(request); repository).getBrowserResult(request);
assertNotNull(result); assertNotNull(result);
List<FileObject> foList = result.getFiles(); FileObject root = result.getFile();
Collection<FileObject> foList = root.getChildren();
assertNotNull(foList); assertNotNull(foList);
assertFalse(foList.isEmpty()); assertFalse(foList.isEmpty());

View File

@@ -35,6 +35,7 @@ package sonia.scm.repository.spi;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.Strings;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -79,11 +80,11 @@ public class SvnBrowseCommand extends AbstractSvnCommand
@Override @Override
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public BrowserResult getBrowserResult(BrowseCommandRequest request) throws RevisionNotFoundException { public BrowserResult getBrowserResult(BrowseCommandRequest request) throws RevisionNotFoundException {
String path = request.getPath(); String path = Strings.nullToEmpty(request.getPath());
long revisionNumber = SvnUtil.getRevisionNumber(request.getRevision()); long revisionNumber = SvnUtil.getRevisionNumber(request.getRevision());
if (logger.isDebugEnabled()) { 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; BrowserResult result = null;
@@ -91,34 +92,21 @@ public class SvnBrowseCommand extends AbstractSvnCommand
try try
{ {
SVNRepository svnRepository = open(); SVNRepository svnRepository = open();
Collection<SVNDirEntry> entries =
svnRepository.getDir(Util.nonNull(path), revisionNumber, null,
(Collection) null);
List<FileObject> 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) { if (revisionNumber == -1) {
revisionNumber = svnRepository.getLatestRevision(); revisionNumber = svnRepository.getLatestRevision();
} }
result = new BrowserResult(); SVNDirEntry rootEntry = svnRepository.info(path, revisionNumber);
result.setRevision(String.valueOf(revisionNumber)); FileObject root = createFileObject(request, svnRepository, revisionNumber, rootEntry, path);
result.setFiles(children); root.setPath(path);
if (root.isDirectory()) {
traverse(svnRepository, revisionNumber, request, root, createBasePath(path));
}
result = new BrowserResult(String.valueOf(revisionNumber), root);
} }
catch (SVNException ex) catch (SVNException ex)
{ {
@@ -130,52 +118,24 @@ public class SvnBrowseCommand extends AbstractSvnCommand
//~--- methods -------------------------------------------------------------- //~--- methods --------------------------------------------------------------
/**
* Method description
*
*
* @param svnRepository
* @param revisionNumber
* @param request
* @param children
* @param entries
* @param basePath
*
* @throws SVNException
*/
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private void browseRecursive(SVNRepository svnRepository, private void traverse(SVNRepository svnRepository, long revisionNumber, BrowseCommandRequest request,
long revisionNumber, BrowseCommandRequest request, FileObject parent, String basePath)
List<FileObject> children, Collection<SVNDirEntry> entries, String basePath)
throws SVNException throws SVNException
{ {
Collection<SVNDirEntry> entries = svnRepository.getDir(parent.getPath(), revisionNumber, null, (Collection) null);
for (SVNDirEntry entry : entries) for (SVNDirEntry entry : entries)
{ {
FileObject fo = createFileObject(request, svnRepository, revisionNumber, FileObject child = createFileObject(request, svnRepository, revisionNumber, entry, basePath);
entry, basePath);
children.add(fo); parent.addChild(child);
if (fo.isDirectory()) if (child.isDirectory() && request.isRecursive()) {
{ traverse(svnRepository, revisionNumber, request, child, createBasePath(child.getPath()));
Collection<SVNDirEntry> subEntries =
svnRepository.getDir(Util.nonNull(fo.getPath()), revisionNumber,
null, (Collection) null);
browseRecursive(svnRepository, revisionNumber, request, children,
subEntries, createBasePath(fo.getPath()));
} }
} }
} }
/**
* Method description
*
*
* @param path
*
* @return
*/
private String createBasePath(String path) private String createBasePath(String path)
{ {
String basePath = Util.EMPTY_STRING; String basePath = Util.EMPTY_STRING;
@@ -193,20 +153,6 @@ public class SvnBrowseCommand extends AbstractSvnCommand
return basePath; return basePath;
} }
/**
* Method description
*
*
*
*
* @param request
* @param repository
* @param revision
* @param entry
* @param path
*
* @return
*/
private FileObject createFileObject(BrowseCommandRequest request, private FileObject createFileObject(BrowseCommandRequest request,
SVNRepository repository, long revision, SVNDirEntry entry, String path) SVNRepository repository, long revision, SVNDirEntry entry, String path)
{ {
@@ -237,15 +183,6 @@ public class SvnBrowseCommand extends AbstractSvnCommand
return fileObject; return fileObject;
} }
/**
* Method description
*
*
* @param repository
* @param revision
* @param entry
* @param fileObject
*/
private void fetchExternalsProperty(SVNRepository repository, long revision, private void fetchExternalsProperty(SVNRepository repository, long revision,
SVNDirEntry entry, FileObject fileObject) SVNDirEntry entry, FileObject fileObject)
{ {

View File

@@ -2,22 +2,24 @@
import React from "react"; import React from "react";
import { repositories } from "@scm-manager/ui-components"; import { repositories } from "@scm-manager/ui-components";
import type { Repository } from "@scm-manager/ui-types"; import type { Repository } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
type Props = { type Props = {
repository: Repository repository: Repository,
t: string => string
} }
class ProtocolInformation extends React.Component<Props> { class ProtocolInformation extends React.Component<Props> {
render() { render() {
const { repository } = this.props; const { repository, t } = this.props;
const href = repositories.getProtocolLinkByType(repository, "http"); const href = repositories.getProtocolLinkByType(repository, "http");
if (!href) { if (!href) {
return null; return null;
} }
return ( return (
<div> <div>
<h4>Checkout the repository</h4> <h4>{t("scm-svn-plugin.information.checkout")}</h4>
<pre> <pre>
<code>svn checkout {href}</code> <code>svn checkout {href}</code>
</pre> </pre>
@@ -27,4 +29,4 @@ class ProtocolInformation extends React.Component<Props> {
} }
export default ProtocolInformation; export default translate("plugins")(ProtocolInformation);

View File

@@ -0,0 +1,7 @@
{
"scm-svn-plugin": {
"information": {
"checkout" : "Repository auschecken"
}
}
}

View File

@@ -0,0 +1,7 @@
{
"scm-svn-plugin": {
"information": {
"checkout" : "Checkout repository"
}
}
}

View File

@@ -33,15 +33,13 @@
package sonia.scm.repository.spi; package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
import org.junit.Test; import org.junit.Test;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.FileObject; import sonia.scm.repository.FileObject;
import sonia.scm.repository.RevisionNotFoundException; import sonia.scm.repository.RevisionNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.Collection;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
@@ -49,8 +47,6 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
//~--- JDK imports ------------------------------------------------------------
/** /**
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
@@ -58,9 +54,19 @@ import static org.junit.Assert.assertTrue;
public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase
{ {
@Test
public void testBrowseWithFilePath() throws RevisionNotFoundException {
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 @Test
public void testBrowse() throws RevisionNotFoundException { public void testBrowse() throws RevisionNotFoundException {
List<FileObject> foList = getRootFromTip(new BrowseCommandRequest()); Collection<FileObject> foList = getRootFromTip(new BrowseCommandRequest());
FileObject a = getFileObject(foList, "a.txt"); FileObject a = getFileObject(foList, "a.txt");
FileObject c = getFileObject(foList, "c"); FileObject c = getFileObject(foList, "c");
@@ -92,7 +98,7 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase
assertNotNull(result); assertNotNull(result);
List<FileObject> foList = result.getFiles(); Collection<FileObject> foList = result.getFile().getChildren();
assertNotNull(foList); assertNotNull(foList);
assertFalse(foList.isEmpty()); assertFalse(foList.isEmpty());
@@ -135,7 +141,7 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase
request.setDisableLastCommit(true); request.setDisableLastCommit(true);
List<FileObject> foList = getRootFromTip(request); Collection<FileObject> foList = getRootFromTip(request);
FileObject a = getFileObject(foList, "a.txt"); FileObject a = getFileObject(foList, "a.txt");
@@ -151,15 +157,16 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase
assertNotNull(result); assertNotNull(result);
List<FileObject> foList = result.getFiles(); Collection<FileObject> foList = result.getFile().getChildren();
assertNotNull(foList); assertNotNull(foList);
assertFalse(foList.isEmpty()); assertFalse(foList.isEmpty());
assertEquals(4, foList.size()); assertEquals(2, foList.size());
for ( FileObject fo : foList ){ FileObject c = getFileObject(foList, "c");
System.out.println(fo); assertEquals("c", c.getName());
} assertTrue(c.isDirectory());
assertEquals(2, c.getChildren().size());
} }
/** /**
@@ -184,31 +191,20 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase
* *
* @return * @return
*/ */
private FileObject getFileObject(List<FileObject> foList, String name) private FileObject getFileObject(Collection<FileObject> foList, String name)
{ {
FileObject a = null; return foList.stream()
.filter(f -> name.equals(f.getName()))
for (FileObject f : foList) .findFirst()
{ .orElseThrow(() -> new AssertionError("file " + name + " not found"));
if (name.equals(f.getName()))
{
a = f;
break;
}
}
assertNotNull(a);
return a;
} }
private List<FileObject> getRootFromTip(BrowseCommandRequest request) throws RevisionNotFoundException { private Collection<FileObject> getRootFromTip(BrowseCommandRequest request) throws RevisionNotFoundException {
BrowserResult result = createCommand().getBrowserResult(request); BrowserResult result = createCommand().getBrowserResult(request);
assertNotNull(result); assertNotNull(result);
List<FileObject> foList = result.getFiles(); Collection<FileObject> foList = result.getFile().getChildren();
assertNotNull(foList); assertNotNull(foList);
assertFalse(foList.isEmpty()); assertFalse(foList.isEmpty());

View File

@@ -4,38 +4,58 @@ import { translate } from "react-i18next";
import PrimaryNavigationLink from "./PrimaryNavigationLink"; import PrimaryNavigationLink from "./PrimaryNavigationLink";
type Props = { type Props = {
t: string => string t: string => string,
repositoriesLink: string,
usersLink: string,
groupsLink: string,
configLink: string,
logoutLink: string
}; };
class PrimaryNavigation extends React.Component<Props> { class PrimaryNavigation extends React.Component<Props> {
render() { render() {
const { t } = this.props; const { t, repositoriesLink, usersLink, groupsLink, configLink, logoutLink } = this.props;
const links = [
repositoriesLink ? (
<PrimaryNavigationLink
to="/repos"
match="/(repo|repos)"
label={t("primary-navigation.repositories")}
key={"repositoriesLink"}
/>): null,
usersLink ? (
<PrimaryNavigationLink
to="/users"
match="/(user|users)"
label={t("primary-navigation.users")}
key={"usersLink"}
/>) : null,
groupsLink ? (
<PrimaryNavigationLink
to="/groups"
match="/(group|groups)"
label={t("primary-navigation.groups")}
key={"groupsLink"}
/>) : null,
configLink ? (
<PrimaryNavigationLink
to="/config"
label={t("primary-navigation.config")}
key={"configLink"}
/>) : null,
logoutLink ? (
<PrimaryNavigationLink
to="/logout"
label={t("primary-navigation.logout")}
key={"logoutLink"}
/>) : null
];
return ( return (
<nav className="tabs is-boxed"> <nav className="tabs is-boxed">
<ul> <ul>
<PrimaryNavigationLink {links}
to="/repos"
match="/(repo|repos)"
label={t("primary-navigation.repositories")}
/>
<PrimaryNavigationLink
to="/users"
match="/(user|users)"
label={t("primary-navigation.users")}
/>
<PrimaryNavigationLink
to="/groups"
match="/(group|groups)"
label={t("primary-navigation.groups")}
/>
<PrimaryNavigationLink
to="/config"
label={t("primary-navigation.config")}
/>
<PrimaryNavigationLink
to="/logout"
label={t("primary-navigation.logout")}
/>
</ul> </ul>
</nav> </nav>
); );

View File

@@ -0,0 +1,7 @@
//@flow
import type { Links } from "./hal";
export type IndexResources = {
version: string,
_links: Links
};

View File

@@ -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[]
}
};

View File

@@ -17,4 +17,8 @@ export type { Tag } from "./Tags";
export type { Config } from "./Config"; export type { Config } from "./Config";
export type { IndexResources } from "./IndexResources";
export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions"; export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions";
export type { SubRepository, File } from "./Sources";

View File

@@ -24,7 +24,8 @@
"navigation-label": "Navigation", "navigation-label": "Navigation",
"history": "Commits", "history": "Commits",
"information": "Information", "information": "Information",
"permissions": "Permissions" "permissions": "Permissions",
"sources": "Sources"
}, },
"create": { "create": {
"title": "Create Repository", "title": "Create Repository",
@@ -45,6 +46,14 @@
"cancel": "No" "cancel": "No"
} }
}, },
"sources": {
"file-tree": {
"name": "Name",
"length": "Length",
"lastModified": "Last modified",
"description": "Description"
}
},
"changesets": { "changesets": {
"error-title": "Error", "error-title": "Error",
"error-subtitle": "Could not fetch changesets", "error-subtitle": "Could not fetch changesets",
@@ -64,29 +73,34 @@
"label": "Branches" "label": "Branches"
}, },
"permission": { "permission": {
"error-title": "Error", "error-title": "Error",
"error-subtitle": "Unknown permissions error", "error-subtitle": "Unknown permissions error",
"name": "User or Group", "name": "User or Group",
"type": "Type", "type": "Type",
"group-permission": "Group Permission", "group-permission": "Group Permission",
"edit-permission": { "edit-permission": {
"delete-button": "Delete", "delete-button": "Delete",
"save-button": "Save Changes" "save-button": "Save Changes"
}, },
"delete-permission-button": { "delete-permission-button": {
"label": "Delete", "label": "Delete",
"confirm-alert": { "confirm-alert": {
"title": "Delete permission", "title": "Delete permission",
"message": "Do you really want to delete the permission?", "message": "Do you really want to delete the permission?",
"submit": "Yes", "submit": "Yes",
"cancel": "No" "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": { "help": {
"nameHelpText": "The name of the repository. This name will be part of the repository url.", "nameHelpText": "The name of the repository. This name will be part of the repository url.",

View File

@@ -14,18 +14,20 @@ import {
modifyConfigReset modifyConfigReset
} from "../modules/config"; } from "../modules/config";
import { connect } from "react-redux"; 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 ConfigForm from "../components/form/ConfigForm";
import { getConfigLink } from "../../modules/indexResource";
type Props = { type Props = {
loading: boolean, loading: boolean,
error: Error, error: Error,
config: Config, config: Config,
configUpdatePermission: boolean, configUpdatePermission: boolean,
configLink: string,
// dispatch functions // dispatch functions
modifyConfig: (config: Config, callback?: () => void) => void, modifyConfig: (config: Config, callback?: () => void) => void,
fetchConfig: void => void, fetchConfig: (link: string) => void,
configReset: void => void, configReset: void => void,
// context objects // context objects
@@ -35,7 +37,7 @@ type Props = {
class GlobalConfig extends React.Component<Props> { class GlobalConfig extends React.Component<Props> {
componentDidMount() { componentDidMount() {
this.props.configReset(); this.props.configReset();
this.props.fetchConfig(); this.props.fetchConfig(this.props.configLink);
} }
modifyConfig = (config: Config) => { modifyConfig = (config: Config) => {
@@ -75,8 +77,8 @@ class GlobalConfig extends React.Component<Props> {
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchConfig: () => { fetchConfig: (link: string) => {
dispatch(fetchConfig()); dispatch(fetchConfig(link));
}, },
modifyConfig: (config: Config, callback?: () => void) => { modifyConfig: (config: Config, callback?: () => void) => {
dispatch(modifyConfig(config, callback)); dispatch(modifyConfig(config, callback));
@@ -92,12 +94,14 @@ const mapStateToProps = state => {
const error = getFetchConfigFailure(state) || getModifyConfigFailure(state); const error = getFetchConfigFailure(state) || getModifyConfigFailure(state);
const config = getConfig(state); const config = getConfig(state);
const configUpdatePermission = getConfigUpdatePermission(state); const configUpdatePermission = getConfigUpdatePermission(state);
const configLink = getConfigLink(state);
return { return {
loading, loading,
error, error,
config, config,
configUpdatePermission configUpdatePermission,
configLink
}; };
}; };

View File

@@ -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_FAILURE = `${MODIFY_CONFIG}_${types.FAILURE_SUFFIX}`;
export const MODIFY_CONFIG_RESET = `${MODIFY_CONFIG}_${types.RESET_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"; const CONTENT_TYPE_CONFIG = "application/vnd.scmm-config+json;v=2";
//fetch config //fetch config
export function fetchConfig() { export function fetchConfig(link: string) {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(fetchConfigPending()); dispatch(fetchConfigPending());
return apiClient return apiClient
.get(CONFIG_URL) .get(link)
.then(response => { .then(response => {
return response.json(); return response.json();
}) })

View File

@@ -22,8 +22,10 @@ import reducer, {
getConfig, getConfig,
getConfigUpdatePermission getConfigUpdatePermission
} from "./config"; } 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!"); const error = new Error("You have an error!");
@@ -103,7 +105,7 @@ describe("config fetch()", () => {
}); });
it("should successfully fetch config", () => { it("should successfully fetch config", () => {
fetchMock.getOnce(CONFIG_URL, response); fetchMock.getOnce(URL, response);
const expectedActions = [ const expectedActions = [
{ type: FETCH_CONFIG_PENDING }, { 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); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
it("should fail getting config on HTTP 500", () => { it("should fail getting config on HTTP 500", () => {
fetchMock.getOnce(CONFIG_URL, { fetchMock.getOnce(URL, {
status: 500 status: 500
}); });
const store = mockStore({}); const store = mockStore({
return store.dispatch(fetchConfig()).then(() => { indexResources: {
links: {
config: {
href: CONFIG_URL
}
}
}
});
return store.dispatch(fetchConfig(CONFIG_URL)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_CONFIG_PENDING); expect(actions[0].type).toEqual(FETCH_CONFIG_PENDING);
expect(actions[1].type).toEqual(FETCH_CONFIG_FAILURE); expect(actions[1].type).toEqual(FETCH_CONFIG_FAILURE);

View File

@@ -19,16 +19,33 @@ import {
Footer, Footer,
Header Header
} from "@scm-manager/ui-components"; } 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 = { type Props = {
me: Me, me: Me,
authenticated: boolean, authenticated: boolean,
error: Error, error: Error,
loading: boolean, loading: boolean,
repositoriesLink: string,
usersLink: string,
groupsLink: string,
configLink: string,
logoutLink: string,
meLink: string,
// dispatcher functions // dispatcher functions
fetchMe: () => void, fetchMe: (link: string) => void,
// context props // context props
t: string => string t: string => string
@@ -36,14 +53,37 @@ type Props = {
class App extends Component<Props> { class App extends Component<Props> {
componentDidMount() { componentDidMount() {
this.props.fetchMe(); if (this.props.meLink) {
this.props.fetchMe(this.props.meLink);
}
} }
render() { render() {
const { me, loading, error, authenticated, t } = this.props; const {
me,
loading,
error,
authenticated,
t,
repositoriesLink,
usersLink,
groupsLink,
configLink,
logoutLink
} = this.props;
let content; let content;
const navigation = authenticated ? <PrimaryNavigation /> : ""; const navigation = authenticated ? (
<PrimaryNavigation
repositoriesLink={repositoriesLink}
usersLink={usersLink}
groupsLink={groupsLink}
configLink={configLink}
logoutLink={logoutLink}
/>
) : (
""
);
if (loading) { if (loading) {
content = <Loading />; content = <Loading />;
@@ -70,20 +110,34 @@ class App extends Component<Props> {
const mapDispatchToProps = (dispatch: any) => { const mapDispatchToProps = (dispatch: any) => {
return { return {
fetchMe: () => dispatch(fetchMe()) fetchMe: (link: string) => dispatch(fetchMe(link))
}; };
}; };
const mapStateToProps = state => { const mapStateToProps = state => {
const authenticated = isAuthenticated(state); const authenticated = isAuthenticated(state);
const me = getMe(state); const me = getMe(state);
const loading = isFetchMePending(state); const loading =
const error = getFetchMeFailure(state); 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 { return {
authenticated, authenticated,
me, me,
loading, loading,
error error,
repositoriesLink,
usersLink,
groupsLink,
configLink,
logoutLink,
meLink
}; };
}; };

View File

@@ -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<Props> {
componentDidMount() {
this.props.fetchIndexResources();
}
render() {
const { indexResources, loading, error, t } = this.props;
if (error) {
return (
<ErrorPage
title={t("app.error.title")}
subtitle={t("app.error.subtitle")}
error={error}
/>
);
} else if (loading || !indexResources) {
return <Loading />;
} else {
return (
<PluginLoader>
<App />
</PluginLoader>
);
}
}
}
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))
);

View File

@@ -18,6 +18,7 @@ import {
Image Image
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import classNames from "classnames"; import classNames from "classnames";
import { getLoginLink } from "../modules/indexResource";
const styles = { const styles = {
avatar: { avatar: {
@@ -41,9 +42,10 @@ type Props = {
authenticated: boolean, authenticated: boolean,
loading: boolean, loading: boolean,
error: Error, error: Error,
link: string,
// dispatcher props // dispatcher props
login: (username: string, password: string) => void, login: (link: string, username: string, password: string) => void,
// context props // context props
t: string => string, t: string => string,
@@ -74,7 +76,11 @@ class Login extends React.Component<Props, State> {
handleSubmit = (event: Event) => { handleSubmit = (event: Event) => {
event.preventDefault(); event.preventDefault();
if (this.isValid()) { 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 authenticated = isAuthenticated(state);
const loading = isLoginPending(state); const loading = isLoginPending(state);
const error = getLoginFailure(state); const error = getLoginFailure(state);
const link = getLoginLink(state);
return { return {
authenticated, authenticated,
loading, loading,
error error,
link
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
login: (username: string, password: string) => login: (loginLink: string, username: string, password: string) =>
dispatch(login(username, password)) dispatch(login(loginLink, username, password))
}; };
}; };

View File

@@ -11,14 +11,16 @@ import {
getLogoutFailure getLogoutFailure
} from "../modules/auth"; } from "../modules/auth";
import { Loading, ErrorPage } from "@scm-manager/ui-components"; import { Loading, ErrorPage } from "@scm-manager/ui-components";
import { fetchIndexResources, getLogoutLink } from "../modules/indexResource";
type Props = { type Props = {
authenticated: boolean, authenticated: boolean,
loading: boolean, loading: boolean,
error: Error, error: Error,
logoutLink: string,
// dispatcher functions // dispatcher functions
logout: () => void, logout: (link: string) => void,
// context props // context props
t: string => string t: string => string
@@ -26,7 +28,7 @@ type Props = {
class Logout extends React.Component<Props> { class Logout extends React.Component<Props> {
componentDidMount() { componentDidMount() {
this.props.logout(); this.props.logout(this.props.logoutLink);
} }
render() { render() {
@@ -51,16 +53,18 @@ const mapStateToProps = state => {
const authenticated = isAuthenticated(state); const authenticated = isAuthenticated(state);
const loading = isLogoutPending(state); const loading = isLogoutPending(state);
const error = getLogoutFailure(state); const error = getLogoutFailure(state);
const logoutLink = getLogoutLink(state);
return { return {
authenticated, authenticated,
loading, loading,
error error,
logoutLink
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
logout: () => dispatch(logout()) logout: (link: string) => dispatch(logout(link))
}; };
}; };

View File

@@ -1,9 +1,12 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import { apiClient, Loading } from "@scm-manager/ui-components"; import { apiClient, Loading } from "@scm-manager/ui-components";
import { getUiPluginsLink } from "../modules/indexResource";
import { connect } from "react-redux";
type Props = { type Props = {
children: React.Node children: React.Node,
link: string
}; };
type State = { type State = {
@@ -29,8 +32,13 @@ class PluginLoader extends React.Component<Props, State> {
this.setState({ this.setState({
message: "loading plugin information" message: "loading plugin information"
}); });
apiClient
.get("ui/plugins") this.getPlugins(this.props.link);
}
getPlugins = (link: string): Promise<any> => {
return apiClient
.get(link)
.then(response => response.text()) .then(response => response.text())
.then(JSON.parse) .then(JSON.parse)
.then(pluginCollection => pluginCollection._embedded.plugins) .then(pluginCollection => pluginCollection._embedded.plugins)
@@ -40,7 +48,7 @@ class PluginLoader extends React.Component<Props, State> {
finished: true finished: true
}); });
}); });
} };
loadPlugins = (plugins: Plugin[]) => { loadPlugins = (plugins: Plugin[]) => {
this.setState({ this.setState({
@@ -87,4 +95,11 @@ class PluginLoader extends React.Component<Props, State> {
} }
} }
export default PluginLoader; const mapStateToProps = state => {
const link = getUiPluginsLink(state);
return {
link
};
};
export default connect(mapStateToProps)(PluginLoader);

View File

@@ -8,12 +8,14 @@ import users from "./users/modules/users";
import repos from "./repos/modules/repos"; import repos from "./repos/modules/repos";
import repositoryTypes from "./repos/modules/repositoryTypes"; import repositoryTypes from "./repos/modules/repositoryTypes";
import changesets from "./repos/modules/changesets"; import changesets from "./repos/modules/changesets";
import sources from "./repos/sources/modules/sources";
import groups from "./groups/modules/groups"; import groups from "./groups/modules/groups";
import auth from "./modules/auth"; import auth from "./modules/auth";
import pending from "./modules/pending"; import pending from "./modules/pending";
import failure from "./modules/failure"; import failure from "./modules/failure";
import permissions from "./repos/permissions/modules/permissions"; import permissions from "./repos/permissions/modules/permissions";
import config from "./config/modules/config"; import config from "./config/modules/config";
import indexResources from "./modules/indexResource";
import type { BrowserHistory } from "history/createBrowserHistory"; import type { BrowserHistory } from "history/createBrowserHistory";
import branches from "./repos/modules/branches"; import branches from "./repos/modules/branches";
@@ -26,6 +28,7 @@ function createReduxStore(history: BrowserHistory) {
router: routerReducer, router: routerReducer,
pending, pending,
failure, failure,
indexResources,
users, users,
repos, repos,
repositoryTypes, repositoryTypes,
@@ -34,7 +37,8 @@ function createReduxStore(history: BrowserHistory) {
permissions, permissions,
groups, groups,
auth, auth,
config config,
sources
}); });
return createStore( return createStore(

View File

@@ -9,18 +9,21 @@ import {
createGroup, createGroup,
isCreateGroupPending, isCreateGroupPending,
getCreateGroupFailure, getCreateGroupFailure,
createGroupReset createGroupReset,
getCreateGroupLink
} from "../modules/groups"; } from "../modules/groups";
import type { Group } from "@scm-manager/ui-types"; import type { Group } from "@scm-manager/ui-types";
import type { History } from "history"; import type { History } from "history";
import { getGroupsLink } from "../../modules/indexResource";
type Props = { type Props = {
t: string => string, t: string => string,
createGroup: (group: Group, callback?: () => void) => void, createGroup: (link: string, group: Group, callback?: () => void) => void,
history: History, history: History,
loading?: boolean, loading?: boolean,
error?: Error, error?: Error,
resetForm: () => void resetForm: () => void,
createLink: string
}; };
type State = {}; type State = {};
@@ -51,14 +54,14 @@ class AddGroup extends React.Component<Props, State> {
this.props.history.push("/groups"); this.props.history.push("/groups");
}; };
createGroup = (group: Group) => { createGroup = (group: Group) => {
this.props.createGroup(group, this.groupCreated); this.props.createGroup(this.props.createLink, group, this.groupCreated);
}; };
} }
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
createGroup: (group: Group, callback?: () => void) => createGroup: (link: string, group: Group, callback?: () => void) =>
dispatch(createGroup(group, callback)), dispatch(createGroup(link, group, callback)),
resetForm: () => { resetForm: () => {
dispatch(createGroupReset()); dispatch(createGroupReset());
} }
@@ -68,7 +71,9 @@ const mapDispatchToProps = dispatch => {
const mapStateToProps = state => { const mapStateToProps = state => {
const loading = isCreateGroupPending(state); const loading = isCreateGroupPending(state);
const error = getCreateGroupFailure(state); const error = getCreateGroupFailure(state);
const createLink = getGroupsLink(state);
return { return {
createLink,
loading, loading,
error error
}; };

View File

@@ -18,6 +18,7 @@ import {
isPermittedToCreateGroups, isPermittedToCreateGroups,
selectListAsCollection selectListAsCollection
} from "../modules/groups"; } from "../modules/groups";
import { getGroupsLink } from "../../modules/indexResource";
type Props = { type Props = {
groups: Group[], groups: Group[],
@@ -26,19 +27,20 @@ type Props = {
canAddGroups: boolean, canAddGroups: boolean,
list: PagedCollection, list: PagedCollection,
page: number, page: number,
groupLink: string,
// context objects // context objects
t: string => string, t: string => string,
history: History, history: History,
// dispatch functions // dispatch functions
fetchGroupsByPage: (page: number) => void, fetchGroupsByPage: (link: string, page: number) => void,
fetchGroupsByLink: (link: string) => void fetchGroupsByLink: (link: string) => void
}; };
class Groups extends React.Component<Props> { class Groups extends React.Component<Props> {
componentDidMount() { componentDidMount() {
this.props.fetchGroupsByPage(this.props.page); this.props.fetchGroupsByPage(this.props.groupLink, this.props.page);
} }
onPageChange = (link: string) => { onPageChange = (link: string) => {
@@ -111,20 +113,23 @@ const mapStateToProps = (state, ownProps) => {
const canAddGroups = isPermittedToCreateGroups(state); const canAddGroups = isPermittedToCreateGroups(state);
const list = selectListAsCollection(state); const list = selectListAsCollection(state);
const groupLink = getGroupsLink(state);
return { return {
groups, groups,
loading, loading,
error, error,
canAddGroups, canAddGroups,
list, list,
page page,
groupLink
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchGroupsByPage: (page: number) => { fetchGroupsByPage: (link: string, page: number) => {
dispatch(fetchGroupsByPage(page)); dispatch(fetchGroupsByPage(link, page));
}, },
fetchGroupsByLink: (link: string) => { fetchGroupsByLink: (link: string) => {
dispatch(fetchGroupsByLink(link)); dispatch(fetchGroupsByLink(link));

View File

@@ -26,16 +26,18 @@ import {
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import EditGroup from "./EditGroup"; import EditGroup from "./EditGroup";
import { getGroupsLink } from "../../modules/indexResource";
type Props = { type Props = {
name: string, name: string,
group: Group, group: Group,
loading: boolean, loading: boolean,
error: Error, error: Error,
groupLink: string,
// dispatcher functions // dispatcher functions
deleteGroup: (group: Group, callback?: () => void) => void, deleteGroup: (group: Group, callback?: () => void) => void,
fetchGroup: string => void, fetchGroup: (string, string) => void,
// context objects // context objects
t: string => string, t: string => string,
@@ -45,7 +47,7 @@ type Props = {
class SingleGroup extends React.Component<Props> { class SingleGroup extends React.Component<Props> {
componentDidMount() { componentDidMount() {
this.props.fetchGroup(this.props.name); this.props.fetchGroup(this.props.groupLink, this.props.name);
} }
stripEndingSlash = (url: string) => { stripEndingSlash = (url: string) => {
@@ -132,19 +134,21 @@ const mapStateToProps = (state, ownProps) => {
isFetchGroupPending(state, name) || isDeleteGroupPending(state, name); isFetchGroupPending(state, name) || isDeleteGroupPending(state, name);
const error = const error =
getFetchGroupFailure(state, name) || getDeleteGroupFailure(state, name); getFetchGroupFailure(state, name) || getDeleteGroupFailure(state, name);
const groupLink = getGroupsLink(state);
return { return {
name, name,
group, group,
loading, loading,
error error,
groupLink
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchGroup: (name: string) => { fetchGroup: (link: string, name: string) => {
dispatch(fetchGroup(name)); dispatch(fetchGroup(link, name));
}, },
deleteGroup: (group: Group, callback?: () => void) => { deleteGroup: (group: Group, callback?: () => void) => {
dispatch(deleteGroup(group, callback)); dispatch(deleteGroup(group, callback));

View File

@@ -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_SUCCESS = `${DELETE_GROUP}_${types.SUCCESS_SUFFIX}`;
export const DELETE_GROUP_FAILURE = `${DELETE_GROUP}_${types.FAILURE_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"; const CONTENT_TYPE_GROUP = "application/vnd.scmm-group+json;v=2";
// fetch groups // fetch groups
export function fetchGroups() { export function fetchGroups(link: string) {
return fetchGroupsByLink(GROUPS_URL); return fetchGroupsByLink(link);
} }
export function fetchGroupsByPage(page: number) { export function fetchGroupsByPage(link: string, page: number) {
// backend start counting by 0 // backend start counting by 0
return fetchGroupsByLink(GROUPS_URL + "?page=" + (page - 1)); return fetchGroupsByLink(link + "?page=" + (page - 1));
} }
export function fetchGroupsByLink(link: string) { export function fetchGroupsByLink(link: string) {
@@ -56,7 +55,7 @@ export function fetchGroupsByLink(link: string) {
}) })
.catch(cause => { .catch(cause => {
const error = new Error(`could not fetch groups: ${cause.message}`); 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 //fetch group
export function fetchGroup(name: string) { export function fetchGroup(link: string, name: string) {
const groupUrl = GROUPS_URL + "/" + name; const groupUrl = link.endsWith("/") ? link + name : link + "/" + name;
return function(dispatch: any) { return function(dispatch: any) {
dispatch(fetchGroupPending(name)); dispatch(fetchGroupPending(name));
return apiClient return apiClient
@@ -132,11 +131,11 @@ export function fetchGroupFailure(name: string, error: Error): Action {
} }
//create group //create group
export function createGroup(group: Group, callback?: () => void) { export function createGroup(link: string, group: Group, callback?: () => void) {
return function(dispatch: Dispatch) { return function(dispatch: Dispatch) {
dispatch(createGroupPending()); dispatch(createGroupPending());
return apiClient return apiClient
.post(GROUPS_URL, group, CONTENT_TYPE_GROUP) .post(link, group, CONTENT_TYPE_GROUP)
.then(() => { .then(() => {
dispatch(createGroupSuccess()); dispatch(createGroupSuccess());
if (callback) { if (callback) {
@@ -410,6 +409,12 @@ export const isPermittedToCreateGroups = (state: Object): boolean => {
return false; 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) { export function getGroupsFromState(state: Object) {
const groupNames = selectList(state).entries; const groupNames = selectList(state).entries;
if (!groupNames) { if (!groupNames) {

View File

@@ -42,9 +42,11 @@ import reducer, {
modifyGroup, modifyGroup,
MODIFY_GROUP_PENDING, MODIFY_GROUP_PENDING,
MODIFY_GROUP_SUCCESS, MODIFY_GROUP_SUCCESS,
MODIFY_GROUP_FAILURE MODIFY_GROUP_FAILURE,
getCreateGroupLink
} from "./groups"; } from "./groups";
const GROUPS_URL = "/api/v2/groups"; const GROUPS_URL = "/api/v2/groups";
const URL = "/groups";
const error = new Error("You have an error!"); const error = new Error("You have an error!");
@@ -63,7 +65,7 @@ const humanGroup = {
href: "http://localhost:8081/api/v2/groups/humanGroup" href: "http://localhost:8081/api/v2/groups/humanGroup"
}, },
update: { update: {
href:"http://localhost:8081/api/v2/groups/humanGroup" href: "http://localhost:8081/api/v2/groups/humanGroup"
} }
}, },
_embedded: { _embedded: {
@@ -95,7 +97,7 @@ const emptyGroup = {
href: "http://localhost:8081/api/v2/groups/emptyGroup" href: "http://localhost:8081/api/v2/groups/emptyGroup"
}, },
update: { update: {
href:"http://localhost:8081/api/v2/groups/emptyGroup" href: "http://localhost:8081/api/v2/groups/emptyGroup"
} }
}, },
_embedded: { _embedded: {
@@ -150,7 +152,7 @@ describe("groups fetch()", () => {
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchGroups()).then(() => { return store.dispatch(fetchGroups(URL)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
@@ -161,7 +163,7 @@ describe("groups fetch()", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchGroups()).then(() => { return store.dispatch(fetchGroups(URL)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_GROUPS_PENDING); expect(actions[0].type).toEqual(FETCH_GROUPS_PENDING);
expect(actions[1].type).toEqual(FETCH_GROUPS_FAILURE); expect(actions[1].type).toEqual(FETCH_GROUPS_FAILURE);
@@ -173,7 +175,7 @@ describe("groups fetch()", () => {
fetchMock.getOnce(GROUPS_URL + "/humanGroup", humanGroup); fetchMock.getOnce(GROUPS_URL + "/humanGroup", humanGroup);
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchGroup("humanGroup")).then(() => { return store.dispatch(fetchGroup(URL, "humanGroup")).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING); expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS); expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS);
@@ -187,7 +189,7 @@ describe("groups fetch()", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchGroup("humanGroup")).then(() => { return store.dispatch(fetchGroup(URL, "humanGroup")).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING); expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE); expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE);
@@ -195,14 +197,13 @@ describe("groups fetch()", () => {
}); });
}); });
it("should successfully create group", () => { it("should successfully create group", () => {
fetchMock.postOnce(GROUPS_URL, { fetchMock.postOnce(GROUPS_URL, {
status: 201 status: 201
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createGroup(humanGroup)).then(() => { return store.dispatch(createGroup(URL, humanGroup)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS); expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
@@ -219,14 +220,13 @@ describe("groups fetch()", () => {
called = true; called = true;
}; };
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createGroup(humanGroup, callMe)).then(() => { return store.dispatch(createGroup(URL, humanGroup, callMe)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS); expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
expect(called).toEqual(true); expect(called).toEqual(true);
}); });
}); });
it("should fail creating group on HTTP 500", () => { it("should fail creating group on HTTP 500", () => {
fetchMock.postOnce(GROUPS_URL, { fetchMock.postOnce(GROUPS_URL, {
@@ -234,7 +234,7 @@ describe("groups fetch()", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createGroup(humanGroup)).then(() => { return store.dispatch(createGroup(URL, humanGroup)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
expect(actions[1].type).toEqual(CREATE_GROUP_FAILURE); expect(actions[1].type).toEqual(CREATE_GROUP_FAILURE);
@@ -248,7 +248,7 @@ describe("groups fetch()", () => {
status: 204 status: 204
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(modifyGroup(humanGroup)).then(() => { return store.dispatch(modifyGroup(humanGroup)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
@@ -267,7 +267,7 @@ describe("groups fetch()", () => {
const callback = () => { const callback = () => {
called = true; called = true;
}; };
const store = mockStore({}); const store = mockStore({});
return store.dispatch(modifyGroup(humanGroup, callback)).then(() => { return store.dispatch(modifyGroup(humanGroup, callback)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
@@ -282,7 +282,7 @@ describe("groups fetch()", () => {
status: 500 status: 500
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(modifyGroup(humanGroup)).then(() => { return store.dispatch(modifyGroup(humanGroup)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
@@ -337,13 +337,10 @@ describe("groups fetch()", () => {
expect(actions[1].payload).toBeDefined(); expect(actions[1].payload).toBeDefined();
}); });
}); });
}); });
describe("groups reducer", () => { describe("groups reducer", () => {
it("should update state correctly according to FETCH_GROUPS_SUCCESS action", () => { it("should update state correctly according to FETCH_GROUPS_SUCCESS action", () => {
const newState = reducer({}, fetchGroupsSuccess(responseBody)); const newState = reducer({}, fetchGroupsSuccess(responseBody));
expect(newState.list).toEqual({ expect(newState.list).toEqual({
@@ -391,7 +388,6 @@ describe("groups reducer", () => {
expect(newState.byNames["humanGroup"]).toBeTruthy(); expect(newState.byNames["humanGroup"]).toBeTruthy();
}); });
it("should update state according to FETCH_GROUP_SUCCESS action", () => { it("should update state according to FETCH_GROUP_SUCCESS action", () => {
const newState = reducer({}, fetchGroupSuccess(emptyGroup)); const newState = reducer({}, fetchGroupSuccess(emptyGroup));
expect(newState.byNames["emptyGroup"]).toBe(emptyGroup); expect(newState.byNames["emptyGroup"]).toBe(emptyGroup);
@@ -426,7 +422,6 @@ describe("groups reducer", () => {
expect(newState.byNames["emptyGroup"]).toBeFalsy(); expect(newState.byNames["emptyGroup"]).toBeFalsy();
expect(newState.list.entries).toEqual(["humanGroup"]); expect(newState.list.entries).toEqual(["humanGroup"]);
}); });
}); });
describe("selector tests", () => { describe("selector tests", () => {
@@ -476,6 +471,23 @@ describe("selector tests", () => {
expect(isPermittedToCreateGroups(state)).toBe(true); 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", () => { it("should get groups from state", () => {
const state = { const state = {
groups: { groups: {
@@ -488,7 +500,7 @@ describe("selector tests", () => {
} }
} }
}; };
expect(getGroupsFromState(state)).toEqual([{ name: "a" }, { name: "b" }]); expect(getGroupsFromState(state)).toEqual([{ name: "a" }, { name: "b" }]);
}); });
@@ -560,9 +572,13 @@ describe("selector tests", () => {
}); });
it("should return true if create group is pending", () => { it("should return true if create group is pending", () => {
expect(isCreateGroupPending({pending: { expect(
[CREATE_GROUP]: true isCreateGroupPending({
}})).toBeTruthy(); pending: {
[CREATE_GROUP]: true
}
})
).toBeTruthy();
}); });
it("should return false if create group is not pending", () => { 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", () => { it("should return error if creating group failed", () => {
expect(getCreateGroupFailure({ expect(
failure: { getCreateGroupFailure({
[CREATE_GROUP]: error failure: {
} [CREATE_GROUP]: error
})).toEqual(error); }
})
).toEqual(error);
}); });
it("should return undefined if creating group did not fail", () => { it("should return undefined if creating group did not fail", () => {
expect(getCreateGroupFailure({})).toBeUndefined(); expect(getCreateGroupFailure({})).toBeUndefined();
}); });
it("should return true, when delete group humanGroup is pending", () => { it("should return true, when delete group humanGroup is pending", () => {
const state = { const state = {
pending: { pending: {

View File

@@ -1,7 +1,7 @@
// @flow // @flow
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import App from "./containers/App"; import Index from "./containers/Index";
import registerServiceWorker from "./registerServiceWorker"; import registerServiceWorker from "./registerServiceWorker";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
@@ -37,9 +37,7 @@ ReactDOM.render(
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
{/* ConnectedRouter will use the store from Provider automatically */} {/* ConnectedRouter will use the store from Provider automatically */}
<ConnectedRouter history={history}> <ConnectedRouter history={history}>
<PluginLoader> <Index />
<App />
</PluginLoader>
</ConnectedRouter> </ConnectedRouter>
</I18nextProvider> </I18nextProvider>
</Provider>, </Provider>,

View File

@@ -5,6 +5,12 @@ import * as types from "./types";
import { apiClient, UNAUTHORIZED_ERROR } from "@scm-manager/ui-components"; import { apiClient, UNAUTHORIZED_ERROR } from "@scm-manager/ui-components";
import { isPending } from "./pending"; import { isPending } from "./pending";
import { getFailure } from "./failure"; import { getFailure } from "./failure";
import {
callFetchIndexResources,
FETCH_INDEXRESOURCES_SUCCESS,
fetchIndexResources, fetchIndexResourcesPending,
fetchIndexResourcesSuccess
} from "./indexResource";
// Action // Action
@@ -121,16 +127,11 @@ export const fetchMeFailure = (error: Error) => {
}; };
}; };
// urls
const ME_URL = "/me";
const LOGIN_URL = "/auth/access_token";
// side effects // side effects
const callFetchMe = (): Promise<Me> => { const callFetchMe = (link: string): Promise<Me> => {
return apiClient return apiClient
.get(ME_URL) .get(link)
.then(response => { .then(response => {
return response.json(); return response.json();
}) })
@@ -139,7 +140,11 @@ const callFetchMe = (): Promise<Me> => {
}); });
}; };
export const login = (username: string, password: string) => { export const login = (
loginLink: string,
username: string,
password: string
) => {
const login_data = { const login_data = {
cookie: true, cookie: true,
grant_type: "password", grant_type: "password",
@@ -149,9 +154,15 @@ export const login = (username: string, password: string) => {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(loginPending()); dispatch(loginPending());
return apiClient return apiClient
.post(LOGIN_URL, login_data) .post(loginLink, login_data)
.then(response => { .then(response => {
return callFetchMe(); dispatch(fetchIndexResourcesPending())
return callFetchIndexResources();
})
.then(response => {
dispatch(fetchIndexResourcesSuccess(response));
const meLink = response._links.me.href;
return callFetchMe(meLink);
}) })
.then(me => { .then(me => {
dispatch(loginSuccess(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) { return function(dispatch: any) {
dispatch(fetchMePending()); dispatch(fetchMePending());
return callFetchMe() return callFetchMe(link)
.then(me => { .then(me => {
dispatch(fetchMeSuccess(me)); dispatch(fetchMeSuccess(me));
}) })
@@ -179,14 +190,17 @@ export const fetchMe = () => {
}; };
}; };
export const logout = () => { export const logout = (link: string) => {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(logoutPending()); dispatch(logoutPending());
return apiClient return apiClient
.delete(LOGIN_URL) .delete(link)
.then(() => { .then(() => {
dispatch(logoutSuccess()); dispatch(logoutSuccess());
}) })
.then(() => {
dispatch(fetchIndexResources());
})
.catch(error => { .catch(error => {
dispatch(logoutFailure(error)); dispatch(logoutFailure(error));
}); });

View File

@@ -32,6 +32,10 @@ import reducer, {
import configureMockStore from "redux-mock-store"; import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk"; import thunk from "redux-thunk";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import {
FETCH_INDEXRESOURCES_PENDING,
FETCH_INDEXRESOURCES_SUCCESS
} from "./indexResource";
const me = { name: "tricia", displayName: "Tricia McMillian" }; const me = { name: "tricia", displayName: "Tricia McMillian" };
@@ -93,16 +97,30 @@ describe("auth actions", () => {
headers: { "content-type": "application/json" } headers: { "content-type": "application/json" }
}); });
const meLink = {
me: {
href: "/me"
}
};
fetchMock.getOnce("/api/v2/", {
_links: meLink
});
const expectedActions = [ const expectedActions = [
{ type: LOGIN_PENDING }, { type: LOGIN_PENDING },
{ type: FETCH_INDEXRESOURCES_PENDING },
{ type: FETCH_INDEXRESOURCES_SUCCESS, payload: { _links: meLink } },
{ type: LOGIN_SUCCESS, payload: me } { type: LOGIN_SUCCESS, payload: me }
]; ];
const store = mockStore({}); const store = mockStore({});
return store.dispatch(login("tricia", "secret123")).then(() => { return store
expect(store.getActions()).toEqual(expectedActions); .dispatch(login("/auth/access_token", "tricia", "secret123"))
}); .then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
}); });
it("should dispatch login failure", () => { it("should dispatch login failure", () => {
@@ -111,12 +129,14 @@ describe("auth actions", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(login("tricia", "secret123")).then(() => { return store
const actions = store.getActions(); .dispatch(login("/auth/access_token", "tricia", "secret123"))
expect(actions[0].type).toEqual(LOGIN_PENDING); .then(() => {
expect(actions[1].type).toEqual(LOGIN_FAILURE); const actions = store.getActions();
expect(actions[1].payload).toBeDefined(); 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", () => { it("should dispatch fetch me success", () => {
@@ -135,7 +155,7 @@ describe("auth actions", () => {
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchMe()).then(() => { return store.dispatch(fetchMe("me")).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
@@ -146,7 +166,7 @@ describe("auth actions", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchMe()).then(() => { return store.dispatch(fetchMe("me")).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_ME_PENDING); expect(actions[0].type).toEqual(FETCH_ME_PENDING);
expect(actions[1].type).toEqual(FETCH_ME_FAILURE); expect(actions[1].type).toEqual(FETCH_ME_FAILURE);
@@ -166,7 +186,7 @@ describe("auth actions", () => {
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchMe()).then(() => { return store.dispatch(fetchMe("me")).then(() => {
// return of async actions // return of async actions
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
@@ -181,14 +201,23 @@ describe("auth actions", () => {
status: 401 status: 401
}); });
fetchMock.getOnce("/api/v2/", {
_links: {
login: {
login: "/login"
}
}
});
const expectedActions = [ const expectedActions = [
{ type: LOGOUT_PENDING }, { type: LOGOUT_PENDING },
{ type: LOGOUT_SUCCESS } { type: LOGOUT_SUCCESS },
{ type: FETCH_INDEXRESOURCES_PENDING }
]; ];
const store = mockStore({}); const store = mockStore({});
return store.dispatch(logout()).then(() => { return store.dispatch(logout("/auth/access_token")).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
@@ -199,7 +228,7 @@ describe("auth actions", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(logout()).then(() => { return store.dispatch(logout("/auth/access_token")).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(LOGOUT_PENDING); expect(actions[0].type).toEqual(LOGOUT_PENDING);
expect(actions[1].type).toEqual(LOGOUT_FAILURE); expect(actions[1].type).toEqual(LOGOUT_FAILURE);

View File

@@ -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<IndexResources> => {
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");
}

View File

@@ -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);
});
});

View File

@@ -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<Props> {
render() {
const { repository, linkName } = this.props;
if (!repository._links[linkName]) {
return null;
}
return <NavLink {...this.props} />;
}
}
export default RepositoryNavLink;

View File

@@ -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(
<RepositoryNavLink
repository={repository}
linkName="sources"
to="/sources"
label="Sources"
/>,
options.get()
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const repository = {
_links: {
sources: {
href: "/sources"
}
}
};
const navLink = mount(
<RepositoryNavLink
repository={repository}
linkName="sources"
to="/sources"
label="Sources"
/>,
options.get()
);
expect(navLink.text()).toBe("Sources");
});
});

View File

@@ -4,18 +4,18 @@ export type Description = {
message: string message: string
}; };
export function parseDescription(description: string): Description { export function parseDescription(description?: string): Description {
let title = ""; const desc = description ? description : "";
const lineBreak = desc.indexOf("\n");
let title;
let message = ""; let message = "";
if (description != null) { if (lineBreak > 0) {
const lineBreak = description.indexOf("\n"); title = desc.substring(0, lineBreak);
if (lineBreak > 0) { message = desc.substring(lineBreak + 1);
title = description.substring(0, lineBreak); } else {
message = description.substring(lineBreak + 1); title = desc;
} else {
title = description;
}
} }
return { return {

View File

@@ -13,4 +13,10 @@ describe("parseDescription tests", () => {
const desc = parseDescription("Hello Trillian"); const desc = parseDescription("Hello Trillian");
expect(desc.title).toBe("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("");
});
}); });

View File

@@ -17,6 +17,7 @@ const styles = {
type Props = { type Props = {
branches: Branch[], // TODO: Use generics? branches: Branch[], // TODO: Use generics?
selected: (branch?: Branch) => void, selected: (branch?: Branch) => void,
selectedBranch: string,
// context props // context props
classes: Object, classes: Object,
@@ -31,6 +32,12 @@ class BranchSelector extends React.Component<Props, State> {
this.state = {}; this.state = {};
} }
componentDidMount() {
this.props.branches
.filter(branch => branch.name === this.props.selectedBranch)
.forEach(branch => this.setState({ selectedBranch: branch }));
}
render() { render() {
const { branches, classes, t } = this.props; const { branches, classes, t } = this.props;
@@ -60,6 +67,8 @@ class BranchSelector extends React.Component<Props, State> {
</div> </div>
</div> </div>
); );
} else {
return null;
} }
} }

View File

@@ -92,11 +92,12 @@ class BranchRoot extends React.Component<Props> {
} }
renderBranchSelector = () => { renderBranchSelector = () => {
const { repository, branches } = this.props; const { repository, branches, selected } = this.props;
if (repository._links.branches) { if (repository._links.branches) {
return ( return (
<BranchSelector <BranchSelector
branches={branches} branches={branches}
selectedBranch={selected}
selected={(b: Branch) => { selected={(b: Branch) => {
this.branchSelected(b); this.branchSelected(b);
}} }}

View File

@@ -18,16 +18,18 @@ import {
isCreateRepoPending isCreateRepoPending
} from "../modules/repos"; } from "../modules/repos";
import type { History } from "history"; import type { History } from "history";
import { getRepositoriesLink } from "../../modules/indexResource";
type Props = { type Props = {
repositoryTypes: RepositoryType[], repositoryTypes: RepositoryType[],
typesLoading: boolean, typesLoading: boolean,
createLoading: boolean, createLoading: boolean,
error: Error, error: Error,
repoLink: string,
// dispatch functions // dispatch functions
fetchRepositoryTypesIfNeeded: () => void, fetchRepositoryTypesIfNeeded: () => void,
createRepo: (Repository, callback: () => void) => void, createRepo: (link: string, Repository, callback: () => void) => void,
resetForm: () => void, resetForm: () => void,
// context props // context props
@@ -55,7 +57,7 @@ class Create extends React.Component<Props> {
error error
} = this.props; } = this.props;
const { t } = this.props; const { t, repoLink } = this.props;
return ( return (
<Page <Page
title={t("create.title")} title={t("create.title")}
@@ -68,7 +70,7 @@ class Create extends React.Component<Props> {
repositoryTypes={repositoryTypes} repositoryTypes={repositoryTypes}
loading={createLoading} loading={createLoading}
submitForm={repo => { submitForm={repo => {
createRepo(repo, this.repoCreated); createRepo(repoLink, repo, this.repoCreated);
}} }}
/> />
</Page> </Page>
@@ -82,11 +84,13 @@ const mapStateToProps = state => {
const createLoading = isCreateRepoPending(state); const createLoading = isCreateRepoPending(state);
const error = const error =
getFetchRepositoryTypesFailure(state) || getCreateRepoFailure(state); getFetchRepositoryTypesFailure(state) || getCreateRepoFailure(state);
const repoLink = getRepositoriesLink(state);
return { return {
repositoryTypes, repositoryTypes,
typesLoading, typesLoading,
createLoading, createLoading,
error error,
repoLink
}; };
}; };
@@ -95,8 +99,12 @@ const mapDispatchToProps = dispatch => {
fetchRepositoryTypesIfNeeded: () => { fetchRepositoryTypesIfNeeded: () => {
dispatch(fetchRepositoryTypesIfNeeded()); dispatch(fetchRepositoryTypesIfNeeded());
}, },
createRepo: (repository: Repository, callback: () => void) => { createRepo: (
dispatch(createRepo(repository, callback)); link: string,
repository: Repository,
callback: () => void
) => {
dispatch(createRepo(link, repository, callback));
}, },
resetForm: () => { resetForm: () => {
dispatch(createRepoReset()); dispatch(createRepoReset());

View File

@@ -18,6 +18,7 @@ import { CreateButton, Page, Paginator } from "@scm-manager/ui-components";
import RepositoryList from "../components/list"; import RepositoryList from "../components/list";
import { withRouter } from "react-router-dom"; import { withRouter } from "react-router-dom";
import type { History } from "history"; import type { History } from "history";
import { getRepositoriesLink } from "../../modules/indexResource";
type Props = { type Props = {
page: number, page: number,
@@ -25,10 +26,11 @@ type Props = {
loading: boolean, loading: boolean,
error: Error, error: Error,
showCreateButton: boolean, showCreateButton: boolean,
reposLink: string,
// dispatched functions // dispatched functions
fetchRepos: () => void, fetchRepos: string => void,
fetchReposByPage: number => void, fetchReposByPage: (string, number) => void,
fetchReposByLink: string => void, fetchReposByLink: string => void,
// context props // context props
@@ -38,7 +40,7 @@ type Props = {
class Overview extends React.Component<Props> { class Overview extends React.Component<Props> {
componentDidMount() { 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 loading = isFetchReposPending(state);
const error = getFetchReposFailure(state); const error = getFetchReposFailure(state);
const showCreateButton = isAbleToCreateRepos(state); const showCreateButton = isAbleToCreateRepos(state);
const reposLink = getRepositoriesLink(state);
return { return {
reposLink,
page, page,
collection, collection,
loading, loading,
@@ -124,11 +128,11 @@ const mapStateToProps = (state, ownProps) => {
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchRepos: () => { fetchRepos: (link: string) => {
dispatch(fetchRepos()); dispatch(fetchRepos(link));
}, },
fetchReposByPage: (page: number) => { fetchReposByPage: (link: string, page: number) => {
dispatch(fetchReposByPage(page)); dispatch(fetchReposByPage(link, page));
}, },
fetchReposByLink: (link: string) => { fetchReposByLink: (link: string) => {
dispatch(fetchReposByLink(link)); dispatch(fetchReposByLink(link));

View File

@@ -16,9 +16,12 @@ import Permissions from "../permissions/containers/Permissions";
import type {History} from "history"; import type {History} from "history";
import EditNavLink from "../components/EditNavLink"; import EditNavLink from "../components/EditNavLink";
import BranchRoot from "./BranchRoot"; import BranchRoot from "./ChangesetsRoot";
import ChangesetView from "./ChangesetView"; import ChangesetView from "./ChangesetView";
import PermissionsNavLink from "../components/PermissionsNavLink"; import PermissionsNavLink from "../components/PermissionsNavLink";
import Sources from "../sources/containers/Sources";
import RepositoryNavLink from "../components/RepositoryNavLink";
import { getRepositoriesLink } from "../../modules/indexResource";
type Props = { type Props = {
namespace: string, namespace: string,
@@ -26,9 +29,10 @@ type Props = {
repository: Repository, repository: Repository,
loading: boolean, loading: boolean,
error: Error, error: Error,
repoLink: string,
// dispatch functions // dispatch functions
fetchRepo: (namespace: string, name: string) => void, fetchRepo: (link: string, namespace: string, name: string) => void,
deleteRepo: (repository: Repository, () => void) => void, deleteRepo: (repository: Repository, () => void) => void,
// context props // context props
@@ -39,9 +43,9 @@ type Props = {
class RepositoryRoot extends React.Component<Props> { class RepositoryRoot extends React.Component<Props> {
componentDidMount() { componentDidMount() {
const { fetchRepo, namespace, name } = this.props; const { fetchRepo, namespace, name, repoLink } = this.props;
fetchRepo(namespace, name); fetchRepo(repoLink, namespace, name);
} }
stripEndingSlash = (url: string) => { stripEndingSlash = (url: string) => {
@@ -115,6 +119,19 @@ class RepositoryRoot extends React.Component<Props> {
path={`${url}/changeset/:id`} path={`${url}/changeset/:id`}
render={() => <ChangesetView repository={repository} />} render={() => <ChangesetView repository={repository} />}
/> />
<Route
path={`${url}/sources`}
exact={true}
render={() => (
<Sources repository={repository} baseUrl={`${url}/sources`} />
)}
/>
<Route
path={`${url}/sources/:revision/:path*`}
render={() => (
<Sources repository={repository} baseUrl={`${url}/sources`} />
)}
/>
<Route <Route
path={`${url}/changesets`} path={`${url}/changesets`}
render={() => ( render={() => (
@@ -141,11 +158,20 @@ class RepositoryRoot extends React.Component<Props> {
<Navigation> <Navigation>
<Section label={t("repository-root.navigation-label")}> <Section label={t("repository-root.navigation-label")}>
<NavLink to={url} label={t("repository-root.information")} /> <NavLink to={url} label={t("repository-root.information")} />
<NavLink <RepositoryNavLink
activeOnlyWhenExact={false} repository={repository}
linkName="changesets"
to={`${url}/changesets/`} to={`${url}/changesets/`}
label={t("repository-root.history")} label={t("repository-root.history")}
activeWhenMatch={this.matches} activeWhenMatch={this.matches}
activeOnlyWhenExact={false}
/>
<RepositoryNavLink
repository={repository}
linkName="sources"
to={`${url}/sources`}
label={t("repository-root.sources")}
activeOnlyWhenExact={false}
/> />
<EditNavLink repository={repository} editUrl={`${url}/edit`} /> <EditNavLink repository={repository} editUrl={`${url}/edit`} />
<PermissionsNavLink <PermissionsNavLink
@@ -170,19 +196,21 @@ const mapStateToProps = (state, ownProps) => {
const repository = getRepository(state, namespace, name); const repository = getRepository(state, namespace, name);
const loading = isFetchRepoPending(state, namespace, name); const loading = isFetchRepoPending(state, namespace, name);
const error = getFetchRepoFailure(state, namespace, name); const error = getFetchRepoFailure(state, namespace, name);
const repoLink = getRepositoriesLink(state);
return { return {
namespace, namespace,
name, name,
repository, repository,
loading, loading,
error error,
repoLink
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchRepo: (namespace: string, name: string) => { fetchRepo: (link: string, namespace: string, name: string) => {
dispatch(fetchRepo(namespace, name)); dispatch(fetchRepo(link, namespace, name));
}, },
deleteRepo: (repository: Repository, callback: () => void) => { deleteRepo: (repository: Repository, callback: () => void) => {
dispatch(deleteRepo(repository, callback)); dispatch(deleteRepo(repository, callback));

View File

@@ -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_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`;
export const DELETE_REPO_FAILURE = `${DELETE_REPO}_${types.FAILURE_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"; const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2";
// fetch repos // fetch repos
const SORT_BY = "sortBy=namespaceAndName"; const SORT_BY = "sortBy=namespaceAndName";
export function fetchRepos() { export function fetchRepos(link: string) {
return fetchReposByLink(REPOS_URL); return fetchReposByLink(link);
} }
export function fetchReposByPage(page: number) { export function fetchReposByPage(link: string, page: number) {
return fetchReposByLink(`${REPOS_URL}?page=${page - 1}`); return fetchReposByLink(`${link}?page=${page - 1}`);
} }
function appendSortByLink(url: string) { function appendSortByLink(url: string) {
@@ -102,11 +100,12 @@ export function fetchReposFailure(err: Error): Action {
// fetch repo // 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) { return function(dispatch: any) {
dispatch(fetchRepoPending(namespace, name)); dispatch(fetchRepoPending(namespace, name));
return apiClient return apiClient
.get(`${REPOS_URL}/${namespace}/${name}`) .get(`${repoUrl}${namespace}/${name}`)
.then(response => response.json()) .then(response => response.json())
.then(repository => { .then(repository => {
dispatch(fetchRepoSuccess(repository)); dispatch(fetchRepoSuccess(repository));
@@ -154,11 +153,15 @@ export function fetchRepoFailure(
// create repo // create repo
export function createRepo(repository: Repository, callback?: () => void) { export function createRepo(
link: string,
repository: Repository,
callback?: () => void
) {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(createRepoPending()); dispatch(createRepoPending());
return apiClient return apiClient
.post(REPOS_URL, repository, CONTENT_TYPE) .post(link, repository, CONTENT_TYPE)
.then(() => { .then(() => {
dispatch(createRepoSuccess()); dispatch(createRepoSuccess());
if (callback) { if (callback) {
@@ -448,3 +451,12 @@ export function getDeleteRepoFailure(
) { ) {
return getFailure(state, DELETE_REPO, namespace + "/" + name); 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;
}

View File

@@ -45,7 +45,8 @@ import reducer, {
MODIFY_REPO, MODIFY_REPO,
isModifyRepoPending, isModifyRepoPending,
getModifyRepoFailure, getModifyRepoFailure,
modifyRepoSuccess modifyRepoSuccess,
getPermissionsLink
} from "./repos"; } from "./repos";
import type { Repository, RepositoryCollection } from "@scm-manager/ui-types"; import type { Repository, RepositoryCollection } from "@scm-manager/ui-types";
@@ -99,16 +100,13 @@ const hitchhikerRestatend: Repository = {
type: "git", type: "git",
_links: { _links: {
self: { self: {
href: href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
"http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
}, },
delete: { delete: {
href: href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
"http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
}, },
update: { update: {
href: href: "http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
"http://localhost:8081/api/v2/repositories/hitchhiker/restatend"
}, },
permissions: { permissions: {
href: href:
@@ -158,16 +156,14 @@ const slartiFjords: Repository = {
href: "http://localhost:8081/api/v2/repositories/slarti/fjords/tags/" href: "http://localhost:8081/api/v2/repositories/slarti/fjords/tags/"
}, },
branches: { branches: {
href: href: "http://localhost:8081/api/v2/repositories/slarti/fjords/branches/"
"http://localhost:8081/api/v2/repositories/slarti/fjords/branches/"
}, },
changesets: { changesets: {
href: href:
"http://localhost:8081/api/v2/repositories/slarti/fjords/changesets/" "http://localhost:8081/api/v2/repositories/slarti/fjords/changesets/"
}, },
sources: { sources: {
href: href: "http://localhost:8081/api/v2/repositories/slarti/fjords/sources/"
"http://localhost:8081/api/v2/repositories/slarti/fjords/sources/"
} }
} }
}; };
@@ -221,6 +217,7 @@ const repositoryCollectionWithNames: RepositoryCollection = {
}; };
describe("repos fetch", () => { describe("repos fetch", () => {
const URL = "repositories";
const REPOS_URL = "/api/v2/repositories"; const REPOS_URL = "/api/v2/repositories";
const SORT = "sortBy=namespaceAndName"; const SORT = "sortBy=namespaceAndName";
const REPOS_URL_WITH_SORT = REPOS_URL + "?" + SORT; const REPOS_URL_WITH_SORT = REPOS_URL + "?" + SORT;
@@ -243,7 +240,7 @@ describe("repos fetch", () => {
]; ];
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchRepos()).then(() => { return store.dispatch(fetchRepos(URL)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
@@ -262,7 +259,7 @@ describe("repos fetch", () => {
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchReposByPage(43)).then(() => { return store.dispatch(fetchReposByPage(URL, 43)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
@@ -318,7 +315,7 @@ describe("repos fetch", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchRepos()).then(() => { return store.dispatch(fetchRepos(URL)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_REPOS_PENDING); expect(actions[0].type).toEqual(FETCH_REPOS_PENDING);
expect(actions[1].type).toEqual(FETCH_REPOS_FAILURE); expect(actions[1].type).toEqual(FETCH_REPOS_FAILURE);
@@ -346,7 +343,7 @@ describe("repos fetch", () => {
]; ];
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchRepo("slarti", "fjords")).then(() => { return store.dispatch(fetchRepo(URL, "slarti", "fjords")).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
@@ -357,7 +354,7 @@ describe("repos fetch", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchRepo("slarti", "fjords")).then(() => { return store.dispatch(fetchRepo(URL, "slarti", "fjords")).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_REPO_PENDING); expect(actions[0].type).toEqual(FETCH_REPO_PENDING);
expect(actions[1].type).toEqual(FETCH_REPO_FAILURE); expect(actions[1].type).toEqual(FETCH_REPO_FAILURE);
@@ -383,7 +380,7 @@ describe("repos fetch", () => {
]; ];
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createRepo(slartiFjords)).then(() => { return store.dispatch(createRepo(URL, slartiFjords)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
@@ -400,7 +397,7 @@ describe("repos fetch", () => {
}; };
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createRepo(slartiFjords, callback)).then(() => { return store.dispatch(createRepo(URL, slartiFjords, callback)).then(() => {
expect(callMe).toBe("yeah"); expect(callMe).toBe("yeah");
}); });
}); });
@@ -411,7 +408,7 @@ describe("repos fetch", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createRepo(slartiFjords)).then(() => { return store.dispatch(createRepo(URL, slartiFjords)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_REPO_PENDING); expect(actions[0].type).toEqual(CREATE_REPO_PENDING);
expect(actions[1].type).toEqual(CREATE_REPO_FAILURE); expect(actions[1].type).toEqual(CREATE_REPO_FAILURE);
@@ -649,6 +646,21 @@ describe("repos selectors", () => {
expect(repository).toEqual(slartiFjords); 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", () => { it("should return true, when fetch repo is pending", () => {
const state = { const state = {
pending: { pending: {

View File

@@ -48,14 +48,17 @@ class CreatePermissionForm extends React.Component<Props, State> {
onChange={this.handleNameChange} onChange={this.handleNameChange}
validationError={!this.state.valid} validationError={!this.state.valid}
errorMessage={t("permission.add-permission.name-input-invalid")} errorMessage={t("permission.add-permission.name-input-invalid")}
helpText={t("permission.help.nameHelpText")}
/> />
<Checkbox <Checkbox
label={t("permission.group-permission")} label={t("permission.group-permission")}
checked={groupPermission ? groupPermission : false} checked={groupPermission ? groupPermission : false}
onChange={this.handleGroupPermissionChange} onChange={this.handleGroupPermissionChange}
helpText={t("permission.help.groupPermissionHelpText")}
/> />
<TypeSelector <TypeSelector
label={t("permission.type")} label={t("permission.type")}
helpText={t("permission.help.typeHelpText")}
handleTypeChange={this.handleTypeChange} handleTypeChange={this.handleTypeChange}
type={type ? type : "READ"} type={type ? type : "READ"}
/> />

View File

@@ -7,13 +7,15 @@ type Props = {
t: string => string, t: string => string,
handleTypeChange: string => void, handleTypeChange: string => void,
type: string, type: string,
label?: string,
helpText?: string,
loading?: boolean loading?: boolean
}; };
class TypeSelector extends React.Component<Props> { class TypeSelector extends React.Component<Props> {
render() { render() {
const { type, handleTypeChange, loading } = this.props; const { type, handleTypeChange, loading, label, helpText } = this.props;
const types = ["READ", "WRITE", "OWNER"]; const types = ["READ", "OWNER", "WRITE"];
return ( return (
<Select <Select
@@ -21,6 +23,8 @@ class TypeSelector extends React.Component<Props> {
value={type ? type : "READ"} value={type ? type : "READ"}
options={this.createSelectOptions(types)} options={this.createSelectOptions(types)}
loading={loading} loading={loading}
label={label}
helpText={helpText}
/> />
); );
} }
@@ -35,4 +39,4 @@ class TypeSelector extends React.Component<Props> {
} }
} }
export default translate("permissions")(TypeSelector); export default translate("repos")(TypeSelector);

View File

@@ -1,27 +1,32 @@
//@flow //@flow
import React from "react"; import React from "react";
import {connect} from "react-redux"; import { connect } from "react-redux";
import {translate} from "react-i18next"; import { translate } from "react-i18next";
import { import {
createPermission,
createPermissionReset,
deletePermissionReset,
fetchPermissions, fetchPermissions,
getCreatePermissionFailure,
getDeletePermissionsFailure,
getFetchPermissionsFailure, getFetchPermissionsFailure,
getModifyPermissionsFailure, isFetchPermissionsPending,
getPermissionsOfRepo, getPermissionsOfRepo,
hasCreatePermission, hasCreatePermission,
createPermission,
isCreatePermissionPending, isCreatePermissionPending,
isFetchPermissionsPending, getCreatePermissionFailure,
modifyPermissionReset createPermissionReset,
getDeletePermissionsFailure,
getModifyPermissionsFailure,
modifyPermissionReset,
deletePermissionReset
} from "../modules/permissions"; } from "../modules/permissions";
import {ErrorPage, Loading} from "@scm-manager/ui-components"; import { Loading, ErrorPage } from "@scm-manager/ui-components";
import type {Permission, PermissionCollection, PermissionCreateEntry} from "@scm-manager/ui-types"; import type {
Permission,
PermissionCollection,
PermissionCreateEntry
} from "@scm-manager/ui-types";
import SinglePermission from "./SinglePermission"; import SinglePermission from "./SinglePermission";
import CreatePermissionForm from "../components/CreatePermissionForm"; import CreatePermissionForm from "../components/CreatePermissionForm";
import type {History} from "history"; import type { History } from "history";
import { getPermissionsLink } from "../../modules/repos";
type Props = { type Props = {
namespace: string, namespace: string,
@@ -31,10 +36,12 @@ type Props = {
permissions: PermissionCollection, permissions: PermissionCollection,
hasPermissionToCreate: boolean, hasPermissionToCreate: boolean,
loadingCreatePermission: boolean, loadingCreatePermission: boolean,
permissionsLink: string,
//dispatch functions //dispatch functions
fetchPermissions: (namespace: string, repoName: string) => void, fetchPermissions: (link: string, namespace: string, repoName: string) => void,
createPermission: ( createPermission: (
link: string,
permission: PermissionCreateEntry, permission: PermissionCreateEntry,
namespace: string, namespace: string,
repoName: string, repoName: string,
@@ -57,17 +64,19 @@ class Permissions extends React.Component<Props> {
repoName, repoName,
modifyPermissionReset, modifyPermissionReset,
createPermissionReset, createPermissionReset,
deletePermissionReset deletePermissionReset,
permissionsLink
} = this.props; } = this.props;
createPermissionReset(namespace, repoName); createPermissionReset(namespace, repoName);
modifyPermissionReset(namespace, repoName); modifyPermissionReset(namespace, repoName);
deletePermissionReset(namespace, repoName); deletePermissionReset(namespace, repoName);
fetchPermissions(namespace, repoName); fetchPermissions(permissionsLink, namespace, repoName);
} }
createPermission = (permission: Permission) => { createPermission = (permission: Permission) => {
this.props.createPermission( this.props.createPermission(
this.props.permissionsLink,
permission, permission,
this.props.namespace, this.props.namespace,
this.props.repoName this.props.repoName
@@ -155,6 +164,7 @@ const mapStateToProps = (state, ownProps) => {
repoName repoName
); );
const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName); const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName);
const permissionsLink = getPermissionsLink(state, namespace, repoName);
return { return {
namespace, namespace,
repoName, repoName,
@@ -162,22 +172,24 @@ const mapStateToProps = (state, ownProps) => {
loading, loading,
permissions, permissions,
hasPermissionToCreate, hasPermissionToCreate,
loadingCreatePermission loadingCreatePermission,
permissionsLink
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchPermissions: (namespace: string, repoName: string) => { fetchPermissions: (link: string, namespace: string, repoName: string) => {
dispatch(fetchPermissions(namespace, repoName)); dispatch(fetchPermissions(link, namespace, repoName));
}, },
createPermission: ( createPermission: (
link: string,
permission: PermissionCreateEntry, permission: PermissionCreateEntry,
namespace: string, namespace: string,
repoName: string, repoName: string,
callback?: () => void callback?: () => void
) => { ) => {
dispatch(createPermission(permission, namespace, repoName, callback)); dispatch(createPermission(link, permission, namespace, repoName, callback));
}, },
createPermissionReset: (namespace: string, repoName: string) => { createPermissionReset: (namespace: string, repoName: string) => {
dispatch(createPermissionReset(namespace, repoName)); dispatch(createPermissionReset(namespace, repoName));

View File

@@ -58,17 +58,19 @@ export const DELETE_PERMISSION_RESET = `${DELETE_PERMISSION}_${
types.RESET_SUFFIX types.RESET_SUFFIX
}`; }`;
const REPOS_URL = "repositories";
const PERMISSIONS_URL = "permissions";
const CONTENT_TYPE = "application/vnd.scmm-permission+json"; const CONTENT_TYPE = "application/vnd.scmm-permission+json";
// fetch permissions // fetch permissions
export function fetchPermissions(namespace: string, repoName: string) { export function fetchPermissions(
link: string,
namespace: string,
repoName: string
) {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(fetchPermissionsPending(namespace, repoName)); dispatch(fetchPermissionsPending(namespace, repoName));
return apiClient return apiClient
.get(`${REPOS_URL}/${namespace}/${repoName}/${PERMISSIONS_URL}`) .get(link)
.then(response => response.json()) .then(response => response.json())
.then(permissions => { .then(permissions => {
dispatch(fetchPermissionsSuccess(permissions, namespace, repoName)); dispatch(fetchPermissionsSuccess(permissions, namespace, repoName));
@@ -215,6 +217,7 @@ export function modifyPermissionReset(namespace: string, repoName: string) {
// create permission // create permission
export function createPermission( export function createPermission(
link: string,
permission: PermissionCreateEntry, permission: PermissionCreateEntry,
namespace: string, namespace: string,
repoName: string, repoName: string,
@@ -223,11 +226,7 @@ export function createPermission(
return function(dispatch: Dispatch) { return function(dispatch: Dispatch) {
dispatch(createPermissionPending(permission, namespace, repoName)); dispatch(createPermissionPending(permission, namespace, repoName));
return apiClient return apiClient
.post( .post(link, permission, CONTENT_TYPE)
`${REPOS_URL}/${namespace}/${repoName}/${PERMISSIONS_URL}`,
permission,
CONTENT_TYPE
)
.then(response => { .then(response => {
const location = response.headers.get("Location"); const location = response.headers.get("Location");
return apiClient.get(location); return apiClient.get(location);

View File

@@ -101,6 +101,7 @@ const hitchhiker_puzzle42RepoPermissions = {
describe("permission fetch", () => { describe("permission fetch", () => {
const REPOS_URL = "/api/v2/repositories"; const REPOS_URL = "/api/v2/repositories";
const URL = "repositories";
const mockStore = configureMockStore([thunk]); const mockStore = configureMockStore([thunk]);
afterEach(() => { afterEach(() => {
@@ -132,7 +133,13 @@ describe("permission fetch", () => {
const store = mockStore({}); const store = mockStore({});
return store return store
.dispatch(fetchPermissions("hitchhiker", "puzzle42")) .dispatch(
fetchPermissions(
URL + "/hitchhiker/puzzle42/permissions",
"hitchhiker",
"puzzle42"
)
)
.then(() => { .then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
@@ -145,7 +152,13 @@ describe("permission fetch", () => {
const store = mockStore({}); const store = mockStore({});
return store return store
.dispatch(fetchPermissions("hitchhiker", "puzzle42")) .dispatch(
fetchPermissions(
URL + "/hitchhiker/puzzle42/permissions",
"hitchhiker",
"puzzle42"
)
)
.then(() => { .then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_PERMISSIONS_PENDING); expect(actions[0].type).toEqual(FETCH_PERMISSIONS_PENDING);
@@ -247,6 +260,7 @@ describe("permission fetch", () => {
return store return store
.dispatch( .dispatch(
createPermission( createPermission(
URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins, hitchhiker_puzzle42Permission_user_eins,
"hitchhiker", "hitchhiker",
"puzzle42" "puzzle42"
@@ -268,6 +282,7 @@ describe("permission fetch", () => {
return store return store
.dispatch( .dispatch(
createPermission( createPermission(
URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins, hitchhiker_puzzle42Permission_user_eins,
"hitchhiker", "hitchhiker",
"puzzle42" "puzzle42"
@@ -304,6 +319,7 @@ describe("permission fetch", () => {
return store return store
.dispatch( .dispatch(
createPermission( createPermission(
URL + "/hitchhiker/puzzle42/permissions",
hitchhiker_puzzle42Permission_user_eins, hitchhiker_puzzle42Permission_user_eins,
"hitchhiker", "hitchhiker",
"puzzle42", "puzzle42",

View File

@@ -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<Props> {
render() {
const { file } = this.props;
let icon = "file";
if (file.subRepository) {
icon = "folder-plus";
} else if (file.directory) {
icon = "folder";
}
return <i className={`fa fa-${icon}`} />;
}
}
export default FileIcon;

View File

@@ -0,0 +1,27 @@
// @flow
import React from "react";
type Props = {
bytes: number
};
class FileSize extends React.Component<Props> {
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 <span>{formattedBytes}</span>;
}
}
export default FileSize;

View File

@@ -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");
});

View File

@@ -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<Props> {
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 <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
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 (
<table className="table table-hover table-sm is-fullwidth">
<thead>
<tr>
<th className={classes.iconColumn} />
<th>{t("sources.file-tree.name")}</th>
<th className="is-hidden-mobile">
{t("sources.file-tree.length")}
</th>
<th className="is-hidden-mobile">
{t("sources.file-tree.lastModified")}
</th>
<th>{t("sources.file-tree.description")}</th>
</tr>
</thead>
<tbody>
{files.map(file => (
<FileTreeLeaf
key={file.name}
file={file}
baseUrl={baseUrlWithRevision}
/>
))}
</tbody>
</table>
);
}
}
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)));

View File

@@ -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("");
});
});

View File

@@ -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<Props> {
createLink = (file: File) => {
return createLink(this.props.baseUrl, file);
};
createFileIcon = (file: File) => {
if (file.directory) {
return (
<Link to={this.createLink(file)}>
<FileIcon file={file} />
</Link>
);
}
return <FileIcon file={file} />;
};
createFileName = (file: File) => {
if (file.directory) {
return <Link to={this.createLink(file)}>{file.name}</Link>;
}
return file.name;
};
render() {
const { file, classes } = this.props;
const fileSize = file.directory ? "" : <FileSize bytes={file.length} />;
return (
<tr>
<td className={classes.iconColumn}>{this.createFileIcon(file)}</td>
<td>{this.createFileName(file)}</td>
<td className="is-hidden-mobile">{fileSize}</td>
<td className="is-hidden-mobile">
<DateFromNow date={file.lastModified} />
</td>
<td>{file.description}</td>
</tr>
);
}
}
export default injectSheet(styles)(FileTreeLeaf);

View File

@@ -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/");
});
});

View File

@@ -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<Props> {
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 <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
return (
<>
{this.renderBranchSelector()}
<FileTree
repository={repository}
revision={revision}
path={path}
baseUrl={baseUrl}
/>
</>
);
}
renderBranchSelector = () => {
const { repository, branches, revision } = this.props;
if (repository._links.branches) {
return (
<BranchSelector
branches={branches}
selectedBranch={revision}
selected={(b: Branch) => {
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);

View File

@@ -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)
);
}

View File

@@ -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);
});
});

View File

@@ -12,13 +12,15 @@ import {
} from "../modules/users"; } from "../modules/users";
import { Page } from "@scm-manager/ui-components"; import { Page } from "@scm-manager/ui-components";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import {getUsersLink} from "../../modules/indexResource";
type Props = { type Props = {
loading?: boolean, loading?: boolean,
error?: Error, error?: Error,
usersLink: string,
// dispatcher functions // dispatcher functions
addUser: (user: User, callback?: () => void) => void, addUser: (link: string, user: User, callback?: () => void) => void,
resetForm: () => void, resetForm: () => void,
// context objects // context objects
@@ -37,7 +39,7 @@ class AddUser extends React.Component<Props> {
}; };
createUser = (user: User) => { createUser = (user: User) => {
this.props.addUser(user, this.userCreated); this.props.addUser(this.props.usersLink, user, this.userCreated);
}; };
render() { render() {
@@ -61,8 +63,8 @@ class AddUser extends React.Component<Props> {
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
addUser: (user: User, callback?: () => void) => { addUser: (link: string, user: User, callback?: () => void) => {
dispatch(createUser(user, callback)); dispatch(createUser(link, user, callback));
}, },
resetForm: () => { resetForm: () => {
dispatch(createUserReset()); dispatch(createUserReset());
@@ -73,7 +75,9 @@ const mapDispatchToProps = dispatch => {
const mapStateToProps = (state, ownProps) => { const mapStateToProps = (state, ownProps) => {
const loading = isCreateUserPending(state); const loading = isCreateUserPending(state);
const error = getCreateUserFailure(state); const error = getCreateUserFailure(state);
const usersLink = getUsersLink(state);
return { return {
usersLink,
loading, loading,
error error
}; };

View File

@@ -26,16 +26,18 @@ import {
import { DeleteUserNavLink, EditUserNavLink } from "./../components/navLinks"; import { DeleteUserNavLink, EditUserNavLink } from "./../components/navLinks";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import { getUsersLink } from "../../modules/indexResource";
type Props = { type Props = {
name: string, name: string,
user: User, user: User,
loading: boolean, loading: boolean,
error: Error, error: Error,
usersLink: string,
// dispatcher functions // dispatcher functions
deleteUser: (user: User, callback?: () => void) => void, deleteUser: (user: User, callback?: () => void) => void,
fetchUser: string => void, fetchUser: (string, string) => void,
// context objects // context objects
t: string => string, t: string => string,
@@ -45,7 +47,7 @@ type Props = {
class SingleUser extends React.Component<Props> { class SingleUser extends React.Component<Props> {
componentDidMount() { componentDidMount() {
this.props.fetchUser(this.props.name); this.props.fetchUser(this.props.usersLink, this.props.name);
} }
userDeleted = () => { userDeleted = () => {
@@ -124,8 +126,9 @@ const mapStateToProps = (state, ownProps) => {
isFetchUserPending(state, name) || isDeleteUserPending(state, name); isFetchUserPending(state, name) || isDeleteUserPending(state, name);
const error = const error =
getFetchUserFailure(state, name) || getDeleteUserFailure(state, name); getFetchUserFailure(state, name) || getDeleteUserFailure(state, name);
const usersLink = getUsersLink(state);
return { return {
usersLink,
name, name,
user, user,
loading, loading,
@@ -135,8 +138,8 @@ const mapStateToProps = (state, ownProps) => {
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchUser: (name: string) => { fetchUser: (link: string, name: string) => {
dispatch(fetchUser(name)); dispatch(fetchUser(link, name));
}, },
deleteUser: (user: User, callback?: () => void) => { deleteUser: (user: User, callback?: () => void) => {
dispatch(deleteUser(user, callback)); dispatch(deleteUser(user, callback));

View File

@@ -18,6 +18,7 @@ import { Page, Paginator } from "@scm-manager/ui-components";
import { UserTable } from "./../components/table"; import { UserTable } from "./../components/table";
import type { User, PagedCollection } from "@scm-manager/ui-types"; import type { User, PagedCollection } from "@scm-manager/ui-types";
import CreateUserButton from "../components/buttons/CreateUserButton"; import CreateUserButton from "../components/buttons/CreateUserButton";
import { getUsersLink } from "../../modules/indexResource";
type Props = { type Props = {
users: User[], users: User[],
@@ -26,19 +27,20 @@ type Props = {
canAddUsers: boolean, canAddUsers: boolean,
list: PagedCollection, list: PagedCollection,
page: number, page: number,
usersLink: string,
// context objects // context objects
t: string => string, t: string => string,
history: History, history: History,
// dispatch functions // dispatch functions
fetchUsersByPage: (page: number) => void, fetchUsersByPage: (link: string, page: number) => void,
fetchUsersByLink: (link: string) => void fetchUsersByLink: (link: string) => void
}; };
class Users extends React.Component<Props> { class Users extends React.Component<Props> {
componentDidMount() { componentDidMount() {
this.props.fetchUsersByPage(this.props.page); this.props.fetchUsersByPage(this.props.usersLink, this.props.page);
} }
onPageChange = (link: string) => { onPageChange = (link: string) => {
@@ -107,6 +109,8 @@ const mapStateToProps = (state, ownProps) => {
const loading = isFetchUsersPending(state); const loading = isFetchUsersPending(state);
const error = getFetchUsersFailure(state); const error = getFetchUsersFailure(state);
const usersLink = getUsersLink(state);
const page = getPageFromProps(ownProps); const page = getPageFromProps(ownProps);
const canAddUsers = isPermittedToCreateUsers(state); const canAddUsers = isPermittedToCreateUsers(state);
const list = selectListAsCollection(state); const list = selectListAsCollection(state);
@@ -117,14 +121,15 @@ const mapStateToProps = (state, ownProps) => {
error, error,
canAddUsers, canAddUsers,
list, list,
page page,
usersLink
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchUsersByPage: (page: number) => { fetchUsersByPage: (link: string, page: number) => {
dispatch(fetchUsersByPage(page)); dispatch(fetchUsersByPage(link, page));
}, },
fetchUsersByLink: (link: string) => { fetchUsersByLink: (link: string) => {
dispatch(fetchUsersByLink(link)); dispatch(fetchUsersByLink(link));

View File

@@ -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_SUCCESS = `${DELETE_USER}_${types.SUCCESS_SUFFIX}`;
export const DELETE_USER_FAILURE = `${DELETE_USER}_${types.FAILURE_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"; const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2";
// TODO i18n for error messages // TODO i18n for error messages
// fetch users // fetch users
export function fetchUsers() { export function fetchUsers(link: string) {
return fetchUsersByLink(USERS_URL); return fetchUsersByLink(link);
} }
export function fetchUsersByPage(page: number) { export function fetchUsersByPage(link: string, page: number) {
// backend start counting by 0 // backend start counting by 0
return fetchUsersByLink(USERS_URL + "?page=" + (page - 1)); return fetchUsersByLink(link + "?page=" + (page - 1));
} }
export function fetchUsersByLink(link: string) { export function fetchUsersByLink(link: string) {
@@ -60,7 +58,7 @@ export function fetchUsersByLink(link: string) {
}) })
.catch(cause => { .catch(cause => {
const error = new Error(`could not fetch users: ${cause.message}`); 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 //fetch user
export function fetchUser(name: string) { export function fetchUser(link: string, name: string) {
const userUrl = USERS_URL + "/" + name; const userUrl = link.endsWith("/") ? link + name : link + "/" + name;
return function(dispatch: any) { return function(dispatch: any) {
dispatch(fetchUserPending(name)); dispatch(fetchUserPending(name));
return apiClient return apiClient
@@ -137,11 +135,11 @@ export function fetchUserFailure(name: string, error: Error): Action {
//create user //create user
export function createUser(user: User, callback?: () => void) { export function createUser(link: string, user: User, callback?: () => void) {
return function(dispatch: Dispatch) { return function(dispatch: Dispatch) {
dispatch(createUserPending(user)); dispatch(createUserPending(user));
return apiClient return apiClient
.post(USERS_URL, user, CONTENT_TYPE_USER) .post(link, user, CONTENT_TYPE_USER)
.then(() => { .then(() => {
dispatch(createUserSuccess()); dispatch(createUserSuccess());
if (callback) { if (callback) {

View File

@@ -122,6 +122,7 @@ const response = {
responseBody responseBody
}; };
const URL = "users";
const USERS_URL = "/api/v2/users"; const USERS_URL = "/api/v2/users";
const error = new Error("KAPUTT"); const error = new Error("KAPUTT");
@@ -146,7 +147,7 @@ describe("users fetch()", () => {
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchUsers()).then(() => { return store.dispatch(fetchUsers(URL)).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
@@ -157,7 +158,7 @@ describe("users fetch()", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchUsers()).then(() => { return store.dispatch(fetchUsers(URL)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USERS_PENDING); expect(actions[0].type).toEqual(FETCH_USERS_PENDING);
expect(actions[1].type).toEqual(FETCH_USERS_FAILURE); expect(actions[1].type).toEqual(FETCH_USERS_FAILURE);
@@ -169,7 +170,7 @@ describe("users fetch()", () => {
fetchMock.getOnce(USERS_URL + "/zaphod", userZaphod); fetchMock.getOnce(USERS_URL + "/zaphod", userZaphod);
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchUser("zaphod")).then(() => { return store.dispatch(fetchUser(URL, "zaphod")).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USER_PENDING); expect(actions[0].type).toEqual(FETCH_USER_PENDING);
expect(actions[1].type).toEqual(FETCH_USER_SUCCESS); expect(actions[1].type).toEqual(FETCH_USER_SUCCESS);
@@ -183,7 +184,7 @@ describe("users fetch()", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchUser("zaphod")).then(() => { return store.dispatch(fetchUser(URL, "zaphod")).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USER_PENDING); expect(actions[0].type).toEqual(FETCH_USER_PENDING);
expect(actions[1].type).toEqual(FETCH_USER_FAILURE); expect(actions[1].type).toEqual(FETCH_USER_FAILURE);
@@ -201,7 +202,7 @@ describe("users fetch()", () => {
fetchMock.getOnce(USERS_URL, response); fetchMock.getOnce(USERS_URL, response);
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createUser(userZaphod)).then(() => { return store.dispatch(createUser(URL, userZaphod)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_USER_PENDING); expect(actions[0].type).toEqual(CREATE_USER_PENDING);
expect(actions[1].type).toEqual(CREATE_USER_SUCCESS); expect(actions[1].type).toEqual(CREATE_USER_SUCCESS);
@@ -214,7 +215,7 @@ describe("users fetch()", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createUser(userZaphod)).then(() => { return store.dispatch(createUser(URL, userZaphod)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_USER_PENDING); expect(actions[0].type).toEqual(CREATE_USER_PENDING);
expect(actions[1].type).toEqual(CREATE_USER_FAILURE); expect(actions[1].type).toEqual(CREATE_USER_FAILURE);
@@ -235,7 +236,7 @@ describe("users fetch()", () => {
}; };
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createUser(userZaphod, callback)).then(() => { return store.dispatch(createUser(URL, userZaphod, callback)).then(() => {
expect(callMe).toBe("yeah"); expect(callMe).toBe("yeah");
}); });
}); });

View File

@@ -23,6 +23,7 @@ import java.net.URL;
* @since 2.0.0 * @since 2.0.0
*/ */
@Singleton @Singleton
@Priority(WebResourceServlet.PRIORITY)
@WebElement(value = WebResourceServlet.PATTERN, regex = true) @WebElement(value = WebResourceServlet.PATTERN, regex = true)
public class WebResourceServlet extends HttpServlet { public class WebResourceServlet extends HttpServlet {
@@ -35,6 +36,9 @@ public class WebResourceServlet extends HttpServlet {
@VisibleForTesting @VisibleForTesting
static final String PATTERN = "/(?!api/|git/|hg/|svn/|hook/|repo/).*"; 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 static final Logger LOG = LoggerFactory.getLogger(WebResourceServlet.class);
private final WebResourceSender sender = WebResourceSender.create() private final WebResourceSender sender = WebResourceSender.create()

View File

@@ -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<FileObjectDto> files) {
this.withEmbedded("files", files);
}
}

View File

@@ -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<FileObjectDto> 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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -1,5 +1,6 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.annotation.JsonInclude;
import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import lombok.Getter; import lombok.Getter;
@@ -7,6 +8,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import java.time.Instant; import java.time.Instant;
import java.util.List;
@Getter @Getter
@Setter @Setter
@@ -15,14 +17,26 @@ public class FileObjectDto extends HalRepresentation {
private String name; private String name;
private String path; private String path;
private boolean directory; private boolean directory;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private String description; private String description;
private int length; private long length;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private Instant lastModified; private Instant lastModified;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private SubRepositoryDto subRepository; private SubRepositoryDto subRepository;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private String revision;
@Override @Override
@SuppressWarnings("squid:S1185") // We want to have this method available in this package @SuppressWarnings("squid:S1185") // We want to have this method available in this package
protected HalRepresentation add(Links links) { protected HalRepresentation add(Links links) {
return super.add(links); return super.add(links);
} }
public void setChildren(List<FileObjectDto> children) {
if (!children.isEmpty()) {
// prevent empty embedded attribute in json
this.withEmbedded("children", children);
}
}
} }

View File

@@ -12,6 +12,9 @@ import sonia.scm.repository.SubRepository;
import javax.inject.Inject; import javax.inject.Inject;
import java.util.List;
import java.util.stream.Collectors;
import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Link.link;
@Mapper @Mapper

View File

@@ -4,6 +4,7 @@ import sonia.scm.repository.NamespaceAndName;
import javax.inject.Inject; import javax.inject.Inject;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
class ResourceLinks { 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' // 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) { 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() { GroupLinks group() {

View File

@@ -3,8 +3,6 @@ package sonia.scm.api.v2.resources;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.RepositoryNotFoundException;
import sonia.scm.repository.RevisionNotFoundException;
import sonia.scm.repository.api.BrowseCommandBuilder; import sonia.scm.repository.api.BrowseCommandBuilder;
import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.api.RepositoryServiceFactory;
@@ -21,26 +19,26 @@ import java.io.IOException;
public class SourceRootResource { public class SourceRootResource {
private final RepositoryServiceFactory serviceFactory; private final RepositoryServiceFactory serviceFactory;
private final BrowserResultToBrowserResultDtoMapper browserResultToBrowserResultDtoMapper; private final BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper;
@Inject @Inject
public SourceRootResource(RepositoryServiceFactory serviceFactory, BrowserResultToBrowserResultDtoMapper browserResultToBrowserResultDtoMapper) { public SourceRootResource(RepositoryServiceFactory serviceFactory, BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper) {
this.serviceFactory = serviceFactory; this.serviceFactory = serviceFactory;
this.browserResultToBrowserResultDtoMapper = browserResultToBrowserResultDtoMapper; this.browserResultToFileObjectDtoMapper = browserResultToFileObjectDtoMapper;
} }
@GET @GET
@Produces(VndMediaType.SOURCE) @Produces(VndMediaType.SOURCE)
@Path("") @Path("")
public Response getAllWithoutRevision(@PathParam("namespace") String namespace, @PathParam("name") String name) throws RevisionNotFoundException, RepositoryNotFoundException, IOException { public Response getAllWithoutRevision(@PathParam("namespace") String namespace, @PathParam("name") String name) throws NotFoundException, IOException {
return getSource(namespace, name, "/", null); return getSource(namespace, name, "/", null);
} }
@GET @GET
@Produces(VndMediaType.SOURCE) @Produces(VndMediaType.SOURCE)
@Path("{revision}") @Path("{revision}")
public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws RevisionNotFoundException, RepositoryNotFoundException, IOException { public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws NotFoundException, IOException {
return getSource(namespace, name, "/", revision); return getSource(namespace, name, "/", revision);
} }
@@ -51,7 +49,7 @@ public class SourceRootResource {
return getSource(namespace, name, path, revision); return getSource(namespace, name, path, revision);
} }
private Response getSource(String namespace, String repoName, String path, String revision) throws IOException, RevisionNotFoundException, RepositoryNotFoundException { private Response getSource(String namespace, String repoName, String path, String revision) throws IOException, NotFoundException {
NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, repoName); NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, repoName);
try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) {
BrowseCommandBuilder browseCommand = repositoryService.getBrowseCommand(); BrowseCommandBuilder browseCommand = repositoryService.getBrowseCommand();
@@ -59,10 +57,11 @@ public class SourceRootResource {
if (revision != null && !revision.isEmpty()) { if (revision != null && !revision.isEmpty()) {
browseCommand.setRevision(revision); browseCommand.setRevision(revision);
} }
browseCommand.setDisableCache(true);
BrowserResult browserResult = browseCommand.getBrowserResult(); BrowserResult browserResult = browseCommand.getBrowserResult();
if (browserResult != null) { if (browserResult != null) {
return Response.ok(browserResultToBrowserResultDtoMapper.map(browserResult, namespaceAndName, path)).build(); return Response.ok(browserResultToFileObjectDtoMapper.map(browserResult, namespaceAndName)).build();
} else { } else {
return Response.status(Response.Status.NOT_FOUND).build(); return Response.status(Response.Status.NOT_FOUND).build();
} }

View File

@@ -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<String, JsonNode> 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<String, Optional<JsonNode>> jsonFileProvider,
BiConsumer<String, JsonNode> createdJsonFileConsumer) {
return Optional.ofNullable(jsonFileProvider.apply(path)
.orElseGet(() -> {
Optional<JsonNode> createdFile = collectJsonFile(path);
createdFile.ifPresent(map -> createdJsonFileConsumer.accept(path, map));
return createdFile.orElse(null);
}
)).orElseThrow(NotFoundException::new);
}
@VisibleForTesting
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse response) {
try (PrintWriter out = response.getWriter()) {
response.setContentType("application/json");
String path = req.getServletPath();
Function<String, Optional<JsonNode>> jsonFileProvider = usedPath -> Optional.empty();
BiConsumer<String, JsonNode> 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<JsonNode> collectJsonFile(String path) {
log.debug("Collect plugin translations from path {} for every plugin", path);
JsonNode mergedJsonNode = null;
try {
Enumeration<URL> 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 <code>updateNode</code> into the <code>mainNode</code> 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<String> 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));
}
}
}
}

View File

@@ -8,34 +8,26 @@ import org.junit.After;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.FileObject; import sonia.scm.repository.FileObject;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import java.net.URI; import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; 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.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks; import static org.mockito.MockitoAnnotations.initMocks;
public class BrowserResultToBrowserResultDtoMapperTest { public class BrowserResultToFileObjectDtoMapperTest {
private final URI baseUri = URI.create("http://example.com/base/"); private final URI baseUri = URI.create("http://example.com/base/");
@SuppressWarnings("unused") // Is injected @SuppressWarnings("unused") // Is injected
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@Mock
private FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper;
@InjectMocks @InjectMocks
private BrowserResultToBrowserResultDtoMapper mapper; private FileObjectToFileObjectDtoMapperImpl fileObjectToFileObjectDtoMapper;
private BrowserResultToFileObjectDtoMapper mapper;
private final Subject subject = mock(Subject.class); private final Subject subject = mock(Subject.class);
private final ThreadState subjectThreadState = new SubjectThreadState(subject); private final ThreadState subjectThreadState = new SubjectThreadState(subject);
@@ -47,6 +39,7 @@ public class BrowserResultToBrowserResultDtoMapperTest {
@Before @Before
public void init() { public void init() {
initMocks(this); initMocks(this);
mapper = new BrowserResultToFileObjectDtoMapper(fileObjectToFileObjectDtoMapper);
subjectThreadState.bind(); subjectThreadState.bind();
ThreadContext.bind(subject); ThreadContext.bind(subject);
@@ -63,9 +56,6 @@ public class BrowserResultToBrowserResultDtoMapperTest {
fileObject2.setPath("/path/object/2"); fileObject2.setPath("/path/object/2");
fileObject2.setDescription("description of file object 2"); fileObject2.setDescription("description of file object 2");
fileObject2.setDirectory(true); fileObject2.setDirectory(true);
when(fileObjectToFileObjectDtoMapper.map(any(FileObject.class), any(NamespaceAndName.class), anyString()))
.thenReturn(new FileObjectDto());
} }
@After @After
@@ -77,7 +67,7 @@ public class BrowserResultToBrowserResultDtoMapperTest {
public void shouldMapAttributesCorrectly() { public void shouldMapAttributesCorrectly() {
BrowserResult browserResult = createBrowserResult(); 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); assertEqualAttributes(browserResult, dto);
} }
@@ -87,10 +77,9 @@ public class BrowserResultToBrowserResultDtoMapperTest {
BrowserResult browserResult = createBrowserResult(); BrowserResult browserResult = createBrowserResult();
NamespaceAndName namespaceAndName = new NamespaceAndName("foo", "bar"); 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"); assertThat(dto.getEmbedded().getItemsBy("children")).hasSize(2);
verify(fileObjectToFileObjectDtoMapper).map(fileObject2, namespaceAndName, "Revision");
} }
@Test @Test
@@ -98,28 +87,27 @@ public class BrowserResultToBrowserResultDtoMapperTest {
BrowserResult browserResult = createBrowserResult(); BrowserResult browserResult = createBrowserResult();
NamespaceAndName namespaceAndName = new NamespaceAndName("foo", "bar"); 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"); assertThat(dto.getLinks().getLinkBy("self").get().getHref()).contains("path");
} }
private BrowserResult createBrowserResult() { private BrowserResult createBrowserResult() {
BrowserResult browserResult = new BrowserResult(); return new BrowserResult("Revision", createFileObject());
browserResult.setRevision("Revision");
browserResult.setFiles(createFileObjects());
return browserResult;
} }
private List<FileObject> createFileObjects() { private FileObject createFileObject() {
List<FileObject> fileObjects = new ArrayList<>(); FileObject file = new FileObject();
file.setName("");
file.setPath("/path");
file.setDirectory(true);
fileObjects.add(fileObject1); file.addChild(fileObject1);
fileObjects.add(fileObject2); file.addChild(fileObject2);
return fileObjects; return file;
} }
private void assertEqualAttributes(BrowserResult browserResult, BrowserResultDto dto) { private void assertEqualAttributes(BrowserResult browserResult, FileObjectDto dto) {
assertThat(dto.getRevision()).isEqualTo(browserResult.getRevision()); assertThat(dto.getRevision()).isEqualTo(browserResult.getRevision());
} }

View File

@@ -173,6 +173,30 @@ public class ResourceLinksTest {
assertEquals(BASE_URL + ConfigResource.CONFIG_PATH_V2, url); 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 @Before
public void initUriInfo() { public void initUriInfo() {
initMocks(this); initMocks(this);

View File

@@ -10,11 +10,11 @@ import org.junit.runner.RunWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.NotFoundException;
import sonia.scm.repository.BrowserResult; import sonia.scm.repository.BrowserResult;
import sonia.scm.repository.FileObject; import sonia.scm.repository.FileObject;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.RepositoryNotFoundException; import sonia.scm.repository.RepositoryNotFoundException;
import sonia.scm.repository.RevisionNotFoundException;
import sonia.scm.repository.api.BrowseCommandBuilder; import sonia.scm.repository.api.BrowseCommandBuilder;
import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.api.RepositoryServiceFactory;
@@ -22,12 +22,8 @@ import sonia.scm.repository.api.RepositoryServiceFactory;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; 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 org.mockito.Mockito.when;
import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher; import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher;
@@ -46,30 +42,25 @@ public class SourceRootResourceTest extends RepositoryTestBase {
@Mock @Mock
private BrowseCommandBuilder browseCommandBuilder; private BrowseCommandBuilder browseCommandBuilder;
@Mock
private FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper;
@InjectMocks @InjectMocks
private BrowserResultToBrowserResultDtoMapper browserResultToBrowserResultDtoMapper; private FileObjectToFileObjectDtoMapperImpl fileObjectToFileObjectDtoMapper;
private BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper;
@Before @Before
public void prepareEnvironment() throws Exception { public void prepareEnvironment() throws Exception {
browserResultToFileObjectDtoMapper = new BrowserResultToFileObjectDtoMapper(fileObjectToFileObjectDtoMapper);
when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(service); when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(service);
when(service.getBrowseCommand()).thenReturn(browseCommandBuilder); when(service.getBrowseCommand()).thenReturn(browseCommandBuilder);
FileObjectDto dto = new FileObjectDto(); SourceRootResource sourceRootResource = new SourceRootResource(serviceFactory, browserResultToFileObjectDtoMapper);
dto.setName("name");
dto.setLength(1024);
when(fileObjectToFileObjectDtoMapper.map(any(FileObject.class), any(NamespaceAndName.class), anyString())).thenReturn(dto);
SourceRootResource sourceRootResource = new SourceRootResource(serviceFactory, browserResultToBrowserResultDtoMapper);
super.sourceRootResource = Providers.of(sourceRootResource); super.sourceRootResource = Providers.of(sourceRootResource);
dispatcher = createDispatcher(getRepositoryRootResource()); dispatcher = createDispatcher(getRepositoryRootResource());
} }
@Test @Test
public void shouldReturnSources() throws URISyntaxException, IOException, RevisionNotFoundException { public void shouldReturnSources() throws URISyntaxException, IOException, NotFoundException {
BrowserResult result = createBrowserResult(); BrowserResult result = createBrowserResult();
when(browseCommandBuilder.getBrowserResult()).thenReturn(result); when(browseCommandBuilder.getBrowserResult()).thenReturn(result);
MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources"); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources");
@@ -77,8 +68,9 @@ public class SourceRootResourceTest extends RepositoryTestBase {
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
System.out.println(response.getContentAsString());
assertThat(response.getContentAsString()).contains("\"revision\":\"revision\""); assertThat(response.getContentAsString()).contains("\"revision\":\"revision\"");
assertThat(response.getContentAsString()).contains("\"files\":"); assertThat(response.getContentAsString()).contains("\"children\":");
} }
@Test @Test
@@ -92,13 +84,11 @@ public class SourceRootResourceTest extends RepositoryTestBase {
} }
@Test @Test
public void shouldGetResultForSingleFile() throws URISyntaxException, IOException, RevisionNotFoundException { public void shouldGetResultForSingleFile() throws URISyntaxException, IOException, NotFoundException {
BrowserResult browserResult = new BrowserResult();
browserResult.setRevision("revision");
FileObject fileObject = new FileObject(); FileObject fileObject = new FileObject();
fileObject.setName("File Object!"); fileObject.setName("File Object!");
fileObject.setPath("/");
browserResult.setFiles(Arrays.asList(fileObject)); BrowserResult browserResult = new BrowserResult("revision", fileObject);
when(browseCommandBuilder.getBrowserResult()).thenReturn(browserResult); when(browseCommandBuilder.getBrowserResult()).thenReturn(browserResult);
MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources/revision/fileabc"); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources/revision/fileabc");
@@ -121,10 +111,15 @@ public class SourceRootResourceTest extends RepositoryTestBase {
} }
private BrowserResult createBrowserResult() { private BrowserResult createBrowserResult() {
return new BrowserResult("revision", "tag", "branch", createFileObjects()); return new BrowserResult("revision", createFileObject());
} }
private List<FileObject> createFileObjects() { private FileObject createFileObject() {
FileObject parent = new FileObject();
parent.setName("bar");
parent.setPath("/foo/bar");
parent.setDirectory(true);
FileObject fileObject1 = new FileObject(); FileObject fileObject1 = new FileObject();
fileObject1.setName("FO 1"); fileObject1.setName("FO 1");
fileObject1.setDirectory(false); fileObject1.setDirectory(false);
@@ -132,6 +127,7 @@ public class SourceRootResourceTest extends RepositoryTestBase {
fileObject1.setPath("/foo/bar/fo1"); fileObject1.setPath("/foo/bar/fo1");
fileObject1.setLength(1024L); fileObject1.setLength(1024L);
fileObject1.setLastModified(0L); fileObject1.setLastModified(0L);
parent.addChild(fileObject1);
FileObject fileObject2 = new FileObject(); FileObject fileObject2 = new FileObject();
fileObject2.setName("FO 2"); fileObject2.setName("FO 2");
@@ -140,7 +136,8 @@ public class SourceRootResourceTest extends RepositoryTestBase {
fileObject2.setPath("/foo/bar/fo2"); fileObject2.setPath("/foo/bar/fo2");
fileObject2.setLength(4096L); fileObject2.setLength(4096L);
fileObject2.setLastModified(1234L); fileObject2.setLastModified(1234L);
parent.addChild(fileObject2);
return Arrays.asList(fileObject1, fileObject2); return parent;
} }
} }

View File

@@ -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<URL> 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<JsonNode> 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;
}
}