mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-05-06 09:27:16 +02:00
Merged 2.0.0-m3 into feature/repository_config_v2_endpoint
This commit is contained in:
4
Jenkinsfile
vendored
4
Jenkinsfile
vendored
@@ -34,6 +34,10 @@ node() { // No specific label
|
||||
mvn 'test -Dsonia.scm.test.skip.hg=true -Dmaven.test.failure.ignore=true'
|
||||
}
|
||||
|
||||
stage('Integration Test') {
|
||||
mvn 'verify -Pit -pl :scm-webapp,:scm-it -Dmaven.test.failure.ignore=true'
|
||||
}
|
||||
|
||||
stage('SonarQube') {
|
||||
|
||||
analyzeWith(mvn)
|
||||
|
||||
1
pom.xml
1
pom.xml
@@ -73,6 +73,7 @@
|
||||
<module>scm-ui</module>
|
||||
<module>scm-webapp</module>
|
||||
<module>scm-server</module>
|
||||
<module>scm-it</module>
|
||||
</modules>
|
||||
|
||||
<repositories>
|
||||
|
||||
@@ -35,6 +35,7 @@ package sonia.scm;
|
||||
|
||||
//~--- non-JDK imports --------------------------------------------------------
|
||||
|
||||
import sonia.scm.repository.RepositoryType;
|
||||
import sonia.scm.security.PermissionDescriptor;
|
||||
import sonia.scm.user.User;
|
||||
|
||||
@@ -82,9 +83,9 @@ public final class ScmState
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public ScmState(String version, User user, Collection<String> groups,
|
||||
String token, Collection<Type> repositoryTypes, String defaultUserType,
|
||||
ScmClientConfig clientConfig, List<String> assignedPermission,
|
||||
List<PermissionDescriptor> availablePermissions)
|
||||
String token, Collection<RepositoryType> repositoryTypes, String defaultUserType,
|
||||
ScmClientConfig clientConfig, List<String> assignedPermission,
|
||||
List<PermissionDescriptor> availablePermissions)
|
||||
{
|
||||
this.version = version;
|
||||
this.user = user;
|
||||
@@ -165,7 +166,7 @@ public final class ScmState
|
||||
*
|
||||
* @return all available repository types
|
||||
*/
|
||||
public Collection<Type> getRepositoryTypes()
|
||||
public Collection<RepositoryType> getRepositoryTypes()
|
||||
{
|
||||
return repositoryTypes;
|
||||
}
|
||||
@@ -244,7 +245,7 @@ public final class ScmState
|
||||
|
||||
/** Field description */
|
||||
@XmlElement(name = "repositoryTypes")
|
||||
private Collection<Type> repositoryTypes;
|
||||
private Collection<RepositoryType> repositoryTypes;
|
||||
|
||||
/** Field description */
|
||||
private User user;
|
||||
|
||||
@@ -5,7 +5,7 @@ import com.google.common.base.Strings;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class NamespaceAndName {
|
||||
public class NamespaceAndName implements Comparable<NamespaceAndName> {
|
||||
|
||||
private final String namespace;
|
||||
private final String name;
|
||||
@@ -47,4 +47,13 @@ public class NamespaceAndName {
|
||||
public int hashCode() {
|
||||
return Objects.hash(namespace, name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(NamespaceAndName o) {
|
||||
int result = namespace.compareTo(o.namespace);
|
||||
if (result == 0) {
|
||||
return name.compareTo(o.name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,18 @@ package sonia.scm.repository;
|
||||
|
||||
import sonia.scm.plugin.ExtensionPoint;
|
||||
|
||||
/**
|
||||
* Strategy to create a namespace for the new repository. Namespaces are used to order and identify repositories.
|
||||
*/
|
||||
@ExtensionPoint
|
||||
public interface NamespaceStrategy {
|
||||
String getNamespace();
|
||||
|
||||
/**
|
||||
* Create new namespace for the given repository.
|
||||
*
|
||||
* @param repository repository
|
||||
*
|
||||
* @return namespace
|
||||
*/
|
||||
String createNamespace(Repository repository);
|
||||
}
|
||||
|
||||
@@ -82,4 +82,7 @@ public interface RepositoryHandler
|
||||
* @since 1.15
|
||||
*/
|
||||
public String getVersionInformation();
|
||||
|
||||
@Override
|
||||
RepositoryType getType();
|
||||
}
|
||||
|
||||
@@ -38,51 +38,11 @@ package sonia.scm.repository;
|
||||
*
|
||||
* @since 1.14
|
||||
*/
|
||||
public class RepositoryIsNotArchivedException extends RepositoryException
|
||||
{
|
||||
public class RepositoryIsNotArchivedException extends RepositoryException {
|
||||
|
||||
/** Field description */
|
||||
private static final long serialVersionUID = 7728748133123987511L;
|
||||
|
||||
//~--- constructors ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Constructs ...
|
||||
*
|
||||
*/
|
||||
public RepositoryIsNotArchivedException() {}
|
||||
|
||||
/**
|
||||
* Constructs ...
|
||||
*
|
||||
*
|
||||
* @param message
|
||||
*/
|
||||
public RepositoryIsNotArchivedException(String message)
|
||||
{
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs ...
|
||||
*
|
||||
*
|
||||
* @param cause
|
||||
*/
|
||||
public RepositoryIsNotArchivedException(Throwable cause)
|
||||
{
|
||||
super(cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs ...
|
||||
*
|
||||
*
|
||||
* @param message
|
||||
* @param cause
|
||||
*/
|
||||
public RepositoryIsNotArchivedException(String message, Throwable cause)
|
||||
{
|
||||
super(message, cause);
|
||||
public RepositoryIsNotArchivedException() {
|
||||
super("Repository could not be deleted, because it is not archived.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,11 @@ package sonia.scm.repository;
|
||||
|
||||
//~--- non-JDK imports --------------------------------------------------------
|
||||
|
||||
import sonia.scm.Type;
|
||||
import sonia.scm.TypeManager;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
@@ -100,7 +98,7 @@ public interface RepositoryManager
|
||||
*
|
||||
* @return all configured repository types
|
||||
*/
|
||||
public Collection<Type> getConfiguredTypes();
|
||||
public Collection<RepositoryType> getConfiguredTypes();
|
||||
|
||||
/**
|
||||
* Returns the {@link Repository} associated to the request uri.
|
||||
@@ -135,11 +133,4 @@ public interface RepositoryManager
|
||||
*/
|
||||
@Override
|
||||
public RepositoryHandler getHandler(String type);
|
||||
|
||||
default Optional<Repository> getByNamespace(String namespace, String name) {
|
||||
return getAll()
|
||||
.stream()
|
||||
.filter(r -> r.getName().equals(name) && r.getNamespace().equals(namespace))
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ public class RepositoryManagerDecorator
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public Collection<Type> getConfiguredTypes()
|
||||
public Collection<RepositoryType> getConfiguredTypes()
|
||||
{
|
||||
return decorated.getConfiguredTypes();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import javax.ws.rs.core.MediaType;
|
||||
* Vendor media types used by SCMM.
|
||||
*/
|
||||
public class VndMediaType {
|
||||
|
||||
private static final String VERSION = "2";
|
||||
private static final String TYPE = "application";
|
||||
private static final String SUBTYPE_PREFIX = "vnd.scmm-";
|
||||
@@ -18,9 +19,10 @@ public class VndMediaType {
|
||||
public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX;
|
||||
public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX;
|
||||
public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX;
|
||||
public static final String ME = PREFIX + "me" + SUFFIX;
|
||||
|
||||
public static final String CONFIG = PREFIX + "config" + SUFFIX;
|
||||
public static final String REPOSITORY_TYPE_COLLECTION = PREFIX + "repositoryTypeCollection" + SUFFIX;
|
||||
public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX;
|
||||
public static final String ME = PREFIX + "me" + SUFFIX;
|
||||
|
||||
private VndMediaType() {
|
||||
}
|
||||
|
||||
240
scm-it/pom.xml
Normal file
240
scm-it/pom.xml
Normal file
@@ -0,0 +1,240 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>sonia.scm</groupId>
|
||||
<artifactId>scm</artifactId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<groupId>sonia.scm</groupId>
|
||||
<artifactId>scm-it</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
<name>scm-it</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>sonia.scm</groupId>
|
||||
<artifactId>scm-core</artifactId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>sonia.scm</groupId>
|
||||
<artifactId>scm-test</artifactId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>sonia.scm.plugins</groupId>
|
||||
<artifactId>scm-git-plugin</artifactId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>sonia.scm.plugins</groupId>
|
||||
<artifactId>scm-git-plugin</artifactId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
<classifier>tests</classifier>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>sonia.scm.plugins</groupId>
|
||||
<artifactId>scm-hg-plugin</artifactId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>sonia.scm.plugins</groupId>
|
||||
<artifactId>scm-hg-plugin</artifactId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
<classifier>tests</classifier>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>sonia.scm.plugins</groupId>
|
||||
<artifactId>scm-svn-plugin</artifactId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>sonia.scm.plugins</groupId>
|
||||
<artifactId>scm-svn-plugin</artifactId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
<classifier>tests</classifier>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.rest-assured</groupId>
|
||||
<artifactId>rest-assured</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>javax</groupId>
|
||||
<artifactId>javaee-api</artifactId>
|
||||
<version>7.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.glassfish</groupId>
|
||||
<artifactId>javax.json</artifactId>
|
||||
<version>1.0.4</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
<plugin>
|
||||
<groupId>com.mycila.maven-license-plugin</groupId>
|
||||
<artifactId>maven-license-plugin</artifactId>
|
||||
<version>1.9.0</version>
|
||||
<configuration>
|
||||
<header>http://download.scm-manager.org/licenses/mvn-license.txt</header>
|
||||
<includes>
|
||||
<include>src/**</include>
|
||||
<include>**/test/**</include>
|
||||
</includes>
|
||||
<excludes>
|
||||
<exclude>target/**</exclude>
|
||||
<exclude>.hg/**</exclude>
|
||||
</excludes>
|
||||
<strictCheck>true</strictCheck>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
|
||||
<profile>
|
||||
<id>it</id>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>2.12</version>
|
||||
<configuration>
|
||||
<includes>
|
||||
<include>sonia/scm/it/*ITCase.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>integration-test</id>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>verify</id>
|
||||
<goals>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<version>2.10</version>
|
||||
<configuration>
|
||||
<artifactItems>
|
||||
<artifactItem>
|
||||
<groupId>sonia.scm</groupId>
|
||||
<artifactId>scm-webapp</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<type>war</type>
|
||||
<outputDirectory>${project.build.outputDirectory}</outputDirectory>
|
||||
<destFileName>scm-webapp.war</destFileName>
|
||||
</artifactItem>
|
||||
</artifactItems>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy-war</id>
|
||||
<phase>pre-integration-test</phase>
|
||||
<goals>
|
||||
<goal>copy</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-maven-plugin</artifactId>
|
||||
<version>${jetty.maven.version}</version>
|
||||
<configuration>
|
||||
<stopPort>8085</stopPort>
|
||||
<stopKey>STOP</stopKey>
|
||||
<systemProperties>
|
||||
<systemProperty>
|
||||
<name>scm.home</name>
|
||||
<value>${scm.home}</value>
|
||||
</systemProperty>
|
||||
<systemProperty>
|
||||
<name>scm.stage</name>
|
||||
<value>${scm.stage}</value>
|
||||
</systemProperty>
|
||||
<systemProperty>
|
||||
<name>java.awt.headless</name>
|
||||
<value>true</value>
|
||||
</systemProperty>
|
||||
</systemProperties>
|
||||
<webApp>
|
||||
<contextPath>/scm</contextPath>
|
||||
</webApp>
|
||||
<jettyXml>${project.basedir}/src/main/conf/jetty.xml</jettyXml>
|
||||
<war>${project.build.outputDirectory}/scm-webapp.war</war>
|
||||
<scanIntervalSeconds>0</scanIntervalSeconds>
|
||||
<daemon>true</daemon>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>start-jetty</id>
|
||||
<phase>pre-integration-test</phase>
|
||||
<goals>
|
||||
<goal>deploy-war</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>stop-jetty</id>
|
||||
<phase>post-integration-test</phase>
|
||||
<goals>
|
||||
<goal>stop</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<properties>
|
||||
<scm.stage>DEVELOPMENT</scm.stage>
|
||||
<scm.home>target/scm-it</scm.home>
|
||||
</properties>
|
||||
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
</project>
|
||||
|
||||
72
scm-it/src/main/conf/jetty.xml
Normal file
72
scm-it/src/main/conf/jetty.xml
Normal file
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_0.dtd">
|
||||
<!--*
|
||||
Copyright (c) 2010, Sebastian Sdorra
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
3. Neither the name of SCM-Manager; nor the names of its
|
||||
contributors may be used to endorse or promote products derived from this
|
||||
software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
http://bitbucket.org/sdorra/scm-manager
|
||||
|
||||
-->
|
||||
|
||||
<Configure id="Server" class="org.eclipse.jetty.server.Server">
|
||||
|
||||
<New id="httpConfig" class="org.eclipse.jetty.server.HttpConfiguration">
|
||||
<!-- increase header size for mercurial -->
|
||||
<Set name="requestHeaderSize">16384</Set>
|
||||
<Set name="responseHeaderSize">16384</Set>
|
||||
</New>
|
||||
|
||||
<Call id="httpConnector" name="addConnector">
|
||||
<Arg>
|
||||
<New class="org.eclipse.jetty.server.ServerConnector">
|
||||
<Arg name="server">
|
||||
<Ref refid="Server" />
|
||||
</Arg>
|
||||
<Arg name="factories">
|
||||
<Array type="org.eclipse.jetty.server.ConnectionFactory">
|
||||
<Item>
|
||||
<New class="org.eclipse.jetty.server.HttpConnectionFactory">
|
||||
<Arg name="config">
|
||||
<Ref refid="httpConfig" />
|
||||
</Arg>
|
||||
</New>
|
||||
</Item>
|
||||
</Array>
|
||||
</Arg>
|
||||
<Set name="host">
|
||||
<Property name="jetty.host" />
|
||||
</Set>
|
||||
<Set name="port">
|
||||
<Property name="jetty.port" default="8081" />
|
||||
</Set>
|
||||
<Set name="idleTimeout">
|
||||
<Property name="http.timeout" default="30000"/>
|
||||
</Set>
|
||||
</New>
|
||||
</Arg>
|
||||
</Call>
|
||||
|
||||
</Configure>
|
||||
29
scm-it/src/test/java/sonia/scm/it/RegExMatcher.java
Normal file
29
scm-it/src/test/java/sonia/scm/it/RegExMatcher.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package sonia.scm.it;
|
||||
|
||||
import org.hamcrest.BaseMatcher;
|
||||
import org.hamcrest.Description;
|
||||
import org.hamcrest.Matcher;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
class RegExMatcher extends BaseMatcher<String> {
|
||||
public static Matcher<String> matchesPattern(String pattern) {
|
||||
return new RegExMatcher(pattern);
|
||||
}
|
||||
|
||||
private final String pattern;
|
||||
|
||||
private RegExMatcher(String pattern) {
|
||||
this.pattern = pattern;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void describeTo(Description description) {
|
||||
description.appendText("matching to regex pattern \"" + pattern + "\"");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matches(Object o) {
|
||||
return Pattern.compile(pattern).matcher(o.toString()).matches();
|
||||
}
|
||||
}
|
||||
198
scm-it/src/test/java/sonia/scm/it/RepositoriesITCase.java
Normal file
198
scm-it/src/test/java/sonia/scm/it/RepositoriesITCase.java
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Copyright (c) 2010, Sebastian Sdorra
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
* 3. Neither the name of SCM-Manager; nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from this
|
||||
* software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
|
||||
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*
|
||||
* http://bitbucket.org/sdorra/scm-manager
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.it;
|
||||
|
||||
//~--- non-JDK imports --------------------------------------------------------
|
||||
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.junit.runners.Parameterized;
|
||||
import org.junit.runners.Parameterized.Parameters;
|
||||
import sonia.scm.repository.Person;
|
||||
import sonia.scm.repository.client.api.ClientCommand;
|
||||
import sonia.scm.repository.client.api.RepositoryClient;
|
||||
import sonia.scm.repository.client.api.RepositoryClientFactory;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static sonia.scm.it.RegExMatcher.matchesPattern;
|
||||
import static sonia.scm.it.RestUtil.createResourceUrl;
|
||||
import static sonia.scm.it.RestUtil.given;
|
||||
import static sonia.scm.it.ScmTypes.availableScmTypes;
|
||||
import static sonia.scm.it.TestData.repositoryJson;
|
||||
|
||||
@RunWith(Parameterized.class)
|
||||
public class RepositoriesITCase {
|
||||
|
||||
public static final Person AUTHOR = new Person("SCM Administrator", "scmadmin@scm-manager.org");
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
private final String repositoryType;
|
||||
|
||||
private String repositoryUrl;
|
||||
|
||||
public RepositoriesITCase(String repositoryType) {
|
||||
this.repositoryType = repositoryType;
|
||||
this.repositoryUrl = TestData.getDefaultRepositoryUrl(repositoryType);
|
||||
}
|
||||
|
||||
@Parameters(name = "{0}")
|
||||
public static Collection<String> createParameters() {
|
||||
return availableScmTypes();
|
||||
}
|
||||
|
||||
@Before
|
||||
public void createRepository() {
|
||||
TestData.createDefault();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateSuccessfully() {
|
||||
given(VndMediaType.REPOSITORY)
|
||||
|
||||
.when()
|
||||
.get(repositoryUrl)
|
||||
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.body(
|
||||
"name", equalTo("HeartOfGold-" + repositoryType),
|
||||
"type", equalTo(repositoryType),
|
||||
"creationDate", matchesPattern("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+Z"),
|
||||
"lastModified", is(nullValue()),
|
||||
"_links.self.href", equalTo(repositoryUrl)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldDeleteSuccessfully() {
|
||||
given(VndMediaType.REPOSITORY)
|
||||
|
||||
.when()
|
||||
.delete(repositoryUrl)
|
||||
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_NO_CONTENT);
|
||||
|
||||
given(VndMediaType.REPOSITORY)
|
||||
|
||||
.when()
|
||||
.get(repositoryUrl)
|
||||
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectMultipleCreations() {
|
||||
String repositoryJson = repositoryJson(repositoryType);
|
||||
given(VndMediaType.REPOSITORY)
|
||||
.body(repositoryJson)
|
||||
|
||||
.when()
|
||||
.post(createResourceUrl("repositories"))
|
||||
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCloneRepository() throws IOException {
|
||||
RepositoryClient client = createRepositoryClient();
|
||||
assertEquals("expected metadata dir", 1, client.getWorkingCopy().list().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCommitFiles() throws IOException {
|
||||
RepositoryClient client = createRepositoryClient();
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
createRandomFile(client);
|
||||
}
|
||||
|
||||
commit(client);
|
||||
|
||||
RepositoryClient checkClient = createRepositoryClient();
|
||||
assertEquals("expected 5 files and metadata dir", 6, checkClient.getWorkingCopy().list().length);
|
||||
}
|
||||
|
||||
private static void createRandomFile(RepositoryClient client) throws IOException {
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
String name = "file-" + uuid + ".uuid";
|
||||
|
||||
File file = new File(client.getWorkingCopy(), name);
|
||||
try (FileOutputStream out = new FileOutputStream(file)) {
|
||||
out.write(uuid.getBytes());
|
||||
}
|
||||
|
||||
client.getAddCommand().add(name);
|
||||
}
|
||||
|
||||
private static void commit(RepositoryClient repositoryClient) throws IOException {
|
||||
repositoryClient.getCommitCommand().commit(AUTHOR, "commit");
|
||||
if ( repositoryClient.isCommandSupported(ClientCommand.PUSH) ) {
|
||||
repositoryClient.getPushCommand().push();
|
||||
}
|
||||
}
|
||||
|
||||
private RepositoryClient createRepositoryClient() throws IOException {
|
||||
RepositoryClientFactory clientFactory = new RepositoryClientFactory();
|
||||
String cloneUrl = readCloneUrl();
|
||||
return clientFactory.create(repositoryType, cloneUrl, "scmadmin", "scmadmin", temporaryFolder.newFolder());
|
||||
}
|
||||
|
||||
private String readCloneUrl() {
|
||||
return given(VndMediaType.REPOSITORY)
|
||||
|
||||
.when()
|
||||
.get(repositoryUrl)
|
||||
|
||||
.then()
|
||||
.extract()
|
||||
.path("_links.httpProtocol.href");
|
||||
}
|
||||
}
|
||||
25
scm-it/src/test/java/sonia/scm/it/RestUtil.java
Normal file
25
scm-it/src/test/java/sonia/scm/it/RestUtil.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package sonia.scm.it;
|
||||
|
||||
import io.restassured.RestAssured;
|
||||
import io.restassured.specification.RequestSpecification;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
import static java.net.URI.create;
|
||||
|
||||
public class RestUtil {
|
||||
|
||||
public static final URI BASE_URL = create("http://localhost:8081/scm/");
|
||||
public static final URI REST_BASE_URL = BASE_URL.resolve("api/rest/v2/");
|
||||
|
||||
public static URI createResourceUrl(String path) {
|
||||
return REST_BASE_URL.resolve(path);
|
||||
}
|
||||
|
||||
public static RequestSpecification given(String mediaType) {
|
||||
return RestAssured.given()
|
||||
.contentType(mediaType)
|
||||
.accept(mediaType)
|
||||
.auth().preemptive().basic("scmadmin", "scmadmin");
|
||||
}
|
||||
}
|
||||
21
scm-it/src/test/java/sonia/scm/it/ScmTypes.java
Normal file
21
scm-it/src/test/java/sonia/scm/it/ScmTypes.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package sonia.scm.it;
|
||||
|
||||
import sonia.scm.util.IOUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
|
||||
class ScmTypes {
|
||||
static Collection<String> availableScmTypes() {
|
||||
Collection<String> params = new ArrayList<>();
|
||||
|
||||
params.add("git");
|
||||
params.add("svn");
|
||||
|
||||
if (IOUtil.search("hg") != null) {
|
||||
params.add("hg");
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
}
|
||||
116
scm-it/src/test/java/sonia/scm/it/TestData.java
Normal file
116
scm-it/src/test/java/sonia/scm/it/TestData.java
Normal file
@@ -0,0 +1,116 @@
|
||||
package sonia.scm.it;
|
||||
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.json.Json;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static java.util.Arrays.asList;
|
||||
import static sonia.scm.it.RestUtil.createResourceUrl;
|
||||
import static sonia.scm.it.RestUtil.given;
|
||||
import static sonia.scm.it.ScmTypes.availableScmTypes;
|
||||
|
||||
public class TestData {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TestData.class);
|
||||
|
||||
private static final List<String> PROTECTED_USERS = asList("scmadmin", "anonymous");
|
||||
|
||||
private static Map<String, String> DEFAULT_REPOSITORIES = new HashMap<>();
|
||||
|
||||
public static void createDefault() {
|
||||
cleanup();
|
||||
createDefaultRepositories();
|
||||
}
|
||||
|
||||
public static void cleanup() {
|
||||
cleanupRepositories();
|
||||
cleanupGroups();
|
||||
cleanupUsers();
|
||||
}
|
||||
|
||||
public static String getDefaultRepositoryUrl(String repositoryType) {
|
||||
return DEFAULT_REPOSITORIES.get(repositoryType);
|
||||
}
|
||||
|
||||
private static void cleanupRepositories() {
|
||||
List<String> repositories = given(VndMediaType.REPOSITORY_COLLECTION)
|
||||
.when()
|
||||
.get(createResourceUrl("repositories"))
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.body().jsonPath().getList("_embedded.repositories._links.self.href");
|
||||
LOG.info("about to delete {} repositories", repositories.size());
|
||||
repositories.forEach(TestData::delete);
|
||||
DEFAULT_REPOSITORIES.clear();
|
||||
}
|
||||
|
||||
private static void cleanupGroups() {
|
||||
List<String> groups = given(VndMediaType.GROUP_COLLECTION)
|
||||
.when()
|
||||
.get(createResourceUrl("groups"))
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.body().jsonPath().getList("_embedded.groups._links.self.href");
|
||||
LOG.info("about to delete {} groups", groups.size());
|
||||
groups.forEach(TestData::delete);
|
||||
}
|
||||
|
||||
private static void cleanupUsers() {
|
||||
List<String> users = given(VndMediaType.USER_COLLECTION)
|
||||
.when()
|
||||
.get(createResourceUrl("users"))
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.body().jsonPath().getList("_embedded.users._links.self.href");
|
||||
LOG.info("about to delete {} users", users.size());
|
||||
users.stream().filter(url -> PROTECTED_USERS.stream().noneMatch(url::contains)).forEach(TestData::delete);
|
||||
}
|
||||
|
||||
private static void delete(String url) {
|
||||
given(VndMediaType.REPOSITORY)
|
||||
.when()
|
||||
.delete(url)
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_NO_CONTENT);
|
||||
LOG.info("deleted {}", url);
|
||||
}
|
||||
|
||||
private static void createDefaultRepositories() {
|
||||
for (String repositoryType : availableScmTypes()) {
|
||||
String url = given(VndMediaType.REPOSITORY)
|
||||
.body(repositoryJson(repositoryType))
|
||||
|
||||
.when()
|
||||
.post(createResourceUrl("repositories"))
|
||||
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_CREATED)
|
||||
.extract()
|
||||
.header("location");
|
||||
DEFAULT_REPOSITORIES.put(repositoryType, url);
|
||||
}
|
||||
}
|
||||
|
||||
public static String repositoryJson(String repositoryType) {
|
||||
return Json.createObjectBuilder()
|
||||
.add("contact", "zaphod.beeblebrox@hitchhiker.com")
|
||||
.add("description", "Heart of Gold")
|
||||
.add("name", "HeartOfGold-" + repositoryType)
|
||||
.add("archived", false)
|
||||
.add("type", repositoryType)
|
||||
.build().toString();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,6 @@ import com.google.inject.Singleton;
|
||||
|
||||
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
|
||||
|
||||
import sonia.scm.Type;
|
||||
import sonia.scm.io.FileSystem;
|
||||
import sonia.scm.plugin.Extension;
|
||||
import sonia.scm.repository.spi.GitRepositoryServiceProvider;
|
||||
@@ -88,7 +87,7 @@ public class GitRepositoryHandler
|
||||
private static final Logger logger = LoggerFactory.getLogger(GitRepositoryHandler.class);
|
||||
|
||||
/** Field description */
|
||||
public static final Type TYPE = new RepositoryType(TYPE_NAME,
|
||||
public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME,
|
||||
TYPE_DISPLAYNAME,
|
||||
GitRepositoryServiceProvider.COMMANDS);
|
||||
|
||||
@@ -167,7 +166,7 @@ public class GitRepositoryHandler
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public Type getType()
|
||||
public RepositoryType getType()
|
||||
{
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@@ -38,32 +38,27 @@ package sonia.scm.web;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
|
||||
import org.eclipse.jgit.http.server.GitServlet;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import static org.slf4j.LoggerFactory.getLogger;
|
||||
|
||||
|
||||
import org.eclipse.jgit.lfs.lib.Constants;
|
||||
import static org.eclipse.jgit.lfs.lib.Constants.CONTENT_TYPE_GIT_LFS_JSON;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryException;
|
||||
import sonia.scm.repository.RepositoryProvider;
|
||||
import sonia.scm.repository.RepositoryRequestListenerUtil;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.web.lfs.servlet.LfsServletFactory;
|
||||
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import sonia.scm.repository.RepositoryException;
|
||||
import java.io.IOException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.eclipse.jgit.lfs.lib.Constants.CONTENT_TYPE_GIT_LFS_JSON;
|
||||
import static org.slf4j.LoggerFactory.getLogger;
|
||||
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -166,12 +161,13 @@ public class ScmGitServlet extends GitServlet
|
||||
* </ul>
|
||||
*/
|
||||
private void handleRequest(HttpServletRequest request, HttpServletResponse response, Repository repository) throws ServletException, IOException {
|
||||
logger.trace("handle git repository at {}", repository.getName());
|
||||
if (isLfsBatchApiRequest(request, repository.getName())) {
|
||||
String repoPath = repository.getNamespace() + "/" + repository.getName();
|
||||
logger.trace("handle git repository at {}", repoPath);
|
||||
if (isLfsBatchApiRequest(request, repoPath)) {
|
||||
HttpServlet servlet = lfsServletFactory.createProtocolServletFor(repository, request);
|
||||
logger.trace("handle lfs batch api request");
|
||||
handleGitLfsRequest(servlet, request, response, repository);
|
||||
} else if (isLfsFileTransferRequest(request, repository.getName())) {
|
||||
} else if (isLfsFileTransferRequest(request, repoPath)) {
|
||||
HttpServlet servlet = lfsServletFactory.createFileLfsServletFor(repository, request);
|
||||
logger.trace("handle lfs file transfer request");
|
||||
handleGitLfsRequest(servlet, request, response, repository);
|
||||
|
||||
@@ -9,13 +9,13 @@ import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.store.BlobStore;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.web.lfs.LfsBlobStoreFactory;
|
||||
import sonia.scm.web.lfs.ScmBlobLfsRepository;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import sonia.scm.web.lfs.LfsBlobStoreFactory;
|
||||
|
||||
/**
|
||||
* This factory class is a helper class to provide the {@link LfsProtocolServlet} and the {@link FileLfsServlet}
|
||||
@@ -70,7 +70,7 @@ public class LfsServletFactory {
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static String buildBaseUri(Repository repository, HttpServletRequest request) {
|
||||
return String.format("%s/git/%s.git/info/lfs/objects/", HttpUtil.getCompleteUrl(request), repository.getName());
|
||||
return String.format("%s/git/%s/%s.git/info/lfs/objects/", HttpUtil.getCompleteUrl(request), repository.getNamespace(), repository.getName());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -17,18 +17,18 @@ import static org.mockito.Mockito.when;
|
||||
public class LfsServletFactoryTest {
|
||||
|
||||
@Test
|
||||
public void buildBaseUri() throws Exception {
|
||||
public void buildBaseUri() {
|
||||
|
||||
String repositoryNamespace = "space";
|
||||
String repositoryName = "git-lfs-demo";
|
||||
|
||||
String result = LfsServletFactory.buildBaseUri(new Repository("", "GIT", repositoryNamespace, repositoryName), RequestWithUri(repositoryName, true));
|
||||
assertThat(result, is(equalTo("http://localhost:8081/scm/git/git-lfs-demo.git/info/lfs/objects/")));
|
||||
assertThat(result, is(equalTo("http://localhost:8081/scm/git/space/git-lfs-demo.git/info/lfs/objects/")));
|
||||
|
||||
|
||||
//result will be with dot-gix suffix, ide
|
||||
//result will be with dot-git suffix, ide
|
||||
result = LfsServletFactory.buildBaseUri(new Repository("", "GIT", repositoryNamespace, repositoryName), RequestWithUri(repositoryName, false));
|
||||
assertThat(result, is(equalTo("http://localhost:8081/scm/git/git-lfs-demo.git/info/lfs/objects/")));
|
||||
assertThat(result, is(equalTo("http://localhost:8081/scm/git/space/git-lfs-demo.git/info/lfs/objects/")));
|
||||
}
|
||||
|
||||
private HttpServletRequest RequestWithUri(String repositoryName, boolean withDotGitSuffix) {
|
||||
|
||||
@@ -44,7 +44,6 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import sonia.scm.ConfigurationException;
|
||||
import sonia.scm.SCMContextProvider;
|
||||
import sonia.scm.Type;
|
||||
import sonia.scm.installer.HgInstaller;
|
||||
import sonia.scm.installer.HgInstallerFactory;
|
||||
import sonia.scm.io.DirectoryFileFilter;
|
||||
@@ -98,7 +97,7 @@ public class HgRepositoryHandler
|
||||
public static final String TYPE_NAME = "hg";
|
||||
|
||||
/** Field description */
|
||||
public static final Type TYPE = new RepositoryType(TYPE_NAME,
|
||||
public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME,
|
||||
TYPE_DISPLAYNAME,
|
||||
HgRepositoryServiceProvider.COMMANDS,
|
||||
HgRepositoryServiceProvider.FEATURES);
|
||||
@@ -259,7 +258,7 @@ public class HgRepositoryHandler
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public Type getType()
|
||||
public RepositoryType getType()
|
||||
{
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ import org.tmatesoft.svn.core.io.SVNRepository;
|
||||
import org.tmatesoft.svn.core.io.SVNRepositoryFactory;
|
||||
import org.tmatesoft.svn.util.SVNDebugLog;
|
||||
|
||||
import sonia.scm.Type;
|
||||
import sonia.scm.io.FileSystem;
|
||||
import sonia.scm.logging.SVNKitLogger;
|
||||
import sonia.scm.plugin.Extension;
|
||||
@@ -87,7 +86,7 @@ public class SvnRepositoryHandler
|
||||
public static final String TYPE_NAME = "svn";
|
||||
|
||||
/** Field description */
|
||||
public static final Type TYPE = new RepositoryType(TYPE_NAME,
|
||||
public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME,
|
||||
TYPE_DISPLAYNAME,
|
||||
SvnRepositoryServiceProvider.COMMANDS);
|
||||
|
||||
@@ -150,7 +149,7 @@ public class SvnRepositoryHandler
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public Type getType()
|
||||
public RepositoryType getType()
|
||||
{
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ package sonia.scm.repository;
|
||||
|
||||
//~--- non-JDK imports --------------------------------------------------------
|
||||
|
||||
import sonia.scm.Type;
|
||||
import com.google.common.collect.Sets;
|
||||
import sonia.scm.io.DefaultFileSystem;
|
||||
import sonia.scm.store.ConfigurationStoreFactory;
|
||||
|
||||
@@ -55,7 +55,7 @@ public class DummyRepositoryHandler
|
||||
|
||||
public static final String TYPE_NAME = "dummy";
|
||||
|
||||
public static final Type TYPE = new Type(TYPE_NAME, TYPE_DISPLAYNAME);
|
||||
public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, TYPE_DISPLAYNAME, Sets.newHashSet());
|
||||
|
||||
private final Set<String> existingRepoNames = new HashSet<>();
|
||||
|
||||
@@ -64,7 +64,7 @@ public class DummyRepositoryHandler
|
||||
}
|
||||
|
||||
@Override
|
||||
public Type getType() {
|
||||
public RepositoryType getType() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"dependencies": {
|
||||
"bulma": "^0.7.1",
|
||||
"classnames": "^2.2.5",
|
||||
"font-awesome": "^4.7.0",
|
||||
"history": "^4.7.2",
|
||||
"i18next": "^11.4.0",
|
||||
"i18next-browser-languagedetector": "^2.2.2",
|
||||
"i18next-fetch-backend": "^0.1.0",
|
||||
"moment": "^2.22.2",
|
||||
"react": "^16.4.1",
|
||||
"react-dom": "^16.4.1",
|
||||
"react-i18next": "^7.9.0",
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
</goals>
|
||||
<configuration>
|
||||
<script>test-ci</script>
|
||||
<skip>${skipTests}</skip>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
|
||||
46
scm-ui/public/locales/en/repos.json
Normal file
46
scm-ui/public/locales/en/repos.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"repository": {
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"contact": "Contact",
|
||||
"description": "Description",
|
||||
"creationDate": "Creation Date",
|
||||
"lastModified": "Last Modified"
|
||||
},
|
||||
"validation": {
|
||||
"name-invalid": "The repository name is invalid",
|
||||
"contact-invalid": "Contact must be a valid mail address"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Repositories",
|
||||
"subtitle": "Overview of available repositories",
|
||||
"create-button": "Create"
|
||||
},
|
||||
"repository-root": {
|
||||
"error-title": "Error",
|
||||
"error-subtitle": "Unknown repository error",
|
||||
"actions-label": "Actions",
|
||||
"back-label": "Back",
|
||||
"navigation-label": "Navigation",
|
||||
"information": "Information"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Repository",
|
||||
"subtitle": "Create a new repository"
|
||||
},
|
||||
"repository-form": {
|
||||
"submit": "Save"
|
||||
},
|
||||
"edit-nav-link": {
|
||||
"label": "Edit"
|
||||
},
|
||||
"delete-nav-action": {
|
||||
"label": "Delete",
|
||||
"confirm-alert": {
|
||||
"title": "Delete repository",
|
||||
"message": "Do you really want to delete the repository?",
|
||||
"submit": "Yes",
|
||||
"cancel": "No"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"repositories": {
|
||||
"title": "Repositories",
|
||||
"subtitle": "Repositories will be shown here",
|
||||
"body": "Coming soon ..."
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
"mail": "E-Mail",
|
||||
"password": "Password",
|
||||
"admin": "Admin",
|
||||
"active": "Active"
|
||||
"active": "Active",
|
||||
"type": "Type",
|
||||
"creationDate": "Creation Date",
|
||||
"lastModified": "Last Modified"
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
|
||||
@@ -27,7 +27,7 @@ function handleStatusCode(response: Response) {
|
||||
}
|
||||
|
||||
export function createUrl(url: string) {
|
||||
if (url.indexOf("://") > 0) {
|
||||
if (url.includes("://")) {
|
||||
return url;
|
||||
}
|
||||
let urlWithStartingSlash = url;
|
||||
@@ -42,26 +42,12 @@ class ApiClient {
|
||||
return fetch(createUrl(url), fetchOptions).then(handleStatusCode);
|
||||
}
|
||||
|
||||
post(url: string, payload: any) {
|
||||
return this.httpRequestWithJSONBody(url, payload, "POST");
|
||||
post(url: string, payload: any, contentType: string = "application/json") {
|
||||
return this.httpRequestWithJSONBody("POST", url, contentType, payload);
|
||||
}
|
||||
|
||||
postWithContentType(url: string, payload: any, contentType: string) {
|
||||
return this.httpRequestWithContentType(
|
||||
url,
|
||||
"POST",
|
||||
JSON.stringify(payload),
|
||||
contentType
|
||||
);
|
||||
}
|
||||
|
||||
putWithContentType(url: string, payload: any, contentType: string) {
|
||||
return this.httpRequestWithContentType(
|
||||
url,
|
||||
"PUT",
|
||||
JSON.stringify(payload),
|
||||
contentType
|
||||
);
|
||||
put(url: string, payload: any, contentType: string = "application/json") {
|
||||
return this.httpRequestWithJSONBody("PUT", url, contentType, payload);
|
||||
}
|
||||
|
||||
delete(url: string): Promise<Response> {
|
||||
@@ -73,37 +59,14 @@ class ApiClient {
|
||||
}
|
||||
|
||||
httpRequestWithJSONBody(
|
||||
url: string,
|
||||
payload: any,
|
||||
method: string
|
||||
): Promise<Response> {
|
||||
// let options: RequestOptions = {
|
||||
// method: method,
|
||||
// body: JSON.stringify(payload)
|
||||
// };
|
||||
// options = Object.assign(options, fetchOptions);
|
||||
// // $FlowFixMe
|
||||
// options.headers["Content-Type"] = "application/json";
|
||||
|
||||
// return fetch(createUrl(url), options).then(handleStatusCode);
|
||||
|
||||
return this.httpRequestWithContentType(
|
||||
url,
|
||||
method,
|
||||
JSON.stringify(payload),
|
||||
"application/json"
|
||||
).then(handleStatusCode);
|
||||
}
|
||||
|
||||
httpRequestWithContentType(
|
||||
url: string,
|
||||
method: string,
|
||||
payload: any,
|
||||
contentType: string
|
||||
url: string,
|
||||
contentType: string,
|
||||
payload: any
|
||||
): Promise<Response> {
|
||||
let options: RequestOptions = {
|
||||
method: method,
|
||||
body: payload
|
||||
body: JSON.stringify(payload)
|
||||
};
|
||||
options = Object.assign(options, fetchOptions);
|
||||
// $FlowFixMe
|
||||
|
||||
32
scm-ui/src/components/DateFromNow.js
Normal file
32
scm-ui/src/components/DateFromNow.js
Normal file
@@ -0,0 +1,32 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import moment from "moment";
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
date?: string,
|
||||
|
||||
// context props
|
||||
i18n: any
|
||||
};
|
||||
|
||||
class DateFromNow extends React.Component<Props> {
|
||||
static format(locale: string, date?: string) {
|
||||
let fromNow = "";
|
||||
if (date) {
|
||||
fromNow = moment(date)
|
||||
.locale(locale)
|
||||
.fromNow();
|
||||
}
|
||||
return fromNow;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { i18n, date } = this.props;
|
||||
|
||||
const fromNow = DateFromNow.format(i18n.language, date);
|
||||
return <span>{fromNow}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(DateFromNow);
|
||||
18
scm-ui/src/components/MailLink.js
Normal file
18
scm-ui/src/components/MailLink.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
address?: string
|
||||
};
|
||||
|
||||
class MailLink extends React.Component<Props> {
|
||||
render() {
|
||||
const { address } = this.props;
|
||||
if (!address) {
|
||||
return null;
|
||||
}
|
||||
return <a href={"mailto: " + address}>{address}</a>;
|
||||
}
|
||||
}
|
||||
|
||||
export default MailLink;
|
||||
@@ -1,11 +0,0 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
class AddButton extends React.Component<ButtonProps> {
|
||||
render() {
|
||||
return <Button type="default" {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default AddButton;
|
||||
@@ -10,7 +10,8 @@ export type ButtonProps = {
|
||||
action?: () => void,
|
||||
link?: string,
|
||||
fullWidth?: boolean,
|
||||
className?: string
|
||||
className?: string,
|
||||
classes: any
|
||||
};
|
||||
|
||||
type Props = ButtonProps & {
|
||||
|
||||
24
scm-ui/src/components/buttons/CreateButton.js
Normal file
24
scm-ui/src/components/buttons/CreateButton.js
Normal file
@@ -0,0 +1,24 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import injectSheet from "react-jss";
|
||||
import Button, { type ButtonProps } from "./Button";
|
||||
import classNames from "classnames";
|
||||
|
||||
const styles = {
|
||||
spacing: {
|
||||
margin: "1em 0 0 1em"
|
||||
}
|
||||
};
|
||||
|
||||
class CreateButton extends React.Component<ButtonProps> {
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div className={classNames("is-pulled-right", classes.spacing)}>
|
||||
<Button type="default" {...this.props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(CreateButton);
|
||||
@@ -1,4 +1,4 @@
|
||||
export { default as AddButton } from "./AddButton";
|
||||
export { default as CreateButton } from "./CreateButton";
|
||||
export { default as Button } from "./Button";
|
||||
export { default as DeleteButton } from "./DeleteButton";
|
||||
export { default as EditButton } from "./EditButton";
|
||||
|
||||
67
scm-ui/src/components/forms/Select.js
Normal file
67
scm-ui/src/components/forms/Select.js
Normal file
@@ -0,0 +1,67 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
export type SelectItem = {
|
||||
value: string,
|
||||
label: string
|
||||
};
|
||||
|
||||
type Props = {
|
||||
label?: string,
|
||||
options: SelectItem[],
|
||||
value?: SelectItem,
|
||||
onChange: string => void
|
||||
};
|
||||
|
||||
class Select extends React.Component<Props> {
|
||||
field: ?HTMLSelectElement;
|
||||
|
||||
componentDidMount() {
|
||||
// trigger change after render, if value is null to set it to the first value
|
||||
// of the given options.
|
||||
if (!this.props.value && this.field && this.field.value) {
|
||||
this.props.onChange(this.field.value);
|
||||
}
|
||||
}
|
||||
|
||||
handleInput = (event: SyntheticInputEvent<HTMLSelectElement>) => {
|
||||
this.props.onChange(event.target.value);
|
||||
};
|
||||
|
||||
renderLabel = () => {
|
||||
const label = this.props.label;
|
||||
if (label) {
|
||||
return <label className="label">{label}</label>;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
render() {
|
||||
const { options, value } = this.props;
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
{this.renderLabel()}
|
||||
<div className="control select">
|
||||
<select
|
||||
ref={input => {
|
||||
this.field = input;
|
||||
}}
|
||||
value={value}
|
||||
onChange={this.handleInput}
|
||||
>
|
||||
{options.map(opt => {
|
||||
return (
|
||||
<option value={opt.value} key={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Select;
|
||||
53
scm-ui/src/components/forms/Textarea.js
Normal file
53
scm-ui/src/components/forms/Textarea.js
Normal file
@@ -0,0 +1,53 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
export type SelectItem = {
|
||||
value: string,
|
||||
label: string
|
||||
};
|
||||
|
||||
type Props = {
|
||||
label?: string,
|
||||
placeholder?: SelectItem[],
|
||||
value?: string,
|
||||
onChange: string => void
|
||||
};
|
||||
|
||||
class Textarea extends React.Component<Props> {
|
||||
field: ?HTMLTextAreaElement;
|
||||
|
||||
handleInput = (event: SyntheticInputEvent<HTMLTextAreaElement>) => {
|
||||
this.props.onChange(event.target.value);
|
||||
};
|
||||
|
||||
renderLabel = () => {
|
||||
const label = this.props.label;
|
||||
if (label) {
|
||||
return <label className="label">{label}</label>;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
render() {
|
||||
const { placeholder, value } = this.props;
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
{this.renderLabel()}
|
||||
<div className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
ref={input => {
|
||||
this.field = input;
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
onChange={this.handleInput}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Textarea;
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as Checkbox } from "./Checkbox";
|
||||
export { default as InputField } from "./InputField";
|
||||
export { default as Select } from "./Select";
|
||||
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
subtitle?: string,
|
||||
loading?: boolean,
|
||||
error?: Error,
|
||||
showContentOnError?: boolean,
|
||||
children: React.Node
|
||||
};
|
||||
|
||||
@@ -35,8 +36,8 @@ class Page extends React.Component<Props> {
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { loading, children, error } = this.props;
|
||||
if (error) {
|
||||
const { loading, children, showContentOnError, error } = this.props;
|
||||
if (error && !showContentOnError) {
|
||||
return null;
|
||||
}
|
||||
if (loading) {
|
||||
|
||||
@@ -14,8 +14,8 @@ class PrimaryNavigation extends React.Component<Props> {
|
||||
<nav className="tabs is-boxed">
|
||||
<ul>
|
||||
<PrimaryNavigationLink
|
||||
to="/"
|
||||
activeOnlyWhenExact={true}
|
||||
to="/repos"
|
||||
match="/(repo|repos)"
|
||||
label={t("primary-navigation.repositories")}
|
||||
/>
|
||||
<PrimaryNavigationLink
|
||||
|
||||
12
scm-ui/src/components/validation.js
Normal file
12
scm-ui/src/components/validation.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// @flow
|
||||
const nameRegex = /^([A-z0-9.\-_@]|[^ ]([A-z0-9.\-_@ ]*[A-z0-9.\-_@]|[^\s])?)$/;
|
||||
|
||||
export const isNameValid = (name: string) => {
|
||||
return nameRegex.test(name);
|
||||
};
|
||||
|
||||
const mailRegex = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-.]*\.[A-z0-9][A-z0-9-]+$/;
|
||||
|
||||
export const isMailValid = (mail: string) => {
|
||||
return mailRegex.test(mail);
|
||||
};
|
||||
75
scm-ui/src/components/validation.test.js
Normal file
75
scm-ui/src/components/validation.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as validator from "./validation";
|
||||
|
||||
describe("test name validation", () => {
|
||||
it("should return false", () => {
|
||||
// invalid names taken from ValidationUtilTest.java
|
||||
const invalidNames = [
|
||||
" test 123",
|
||||
" test 123 ",
|
||||
"test 123 ",
|
||||
"test/123",
|
||||
"test%123",
|
||||
"test:123",
|
||||
"t ",
|
||||
" t",
|
||||
" t ",
|
||||
""
|
||||
];
|
||||
for (let name of invalidNames) {
|
||||
expect(validator.isNameValid(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return true", () => {
|
||||
// valid names taken from ValidationUtilTest.java
|
||||
const validNames = [
|
||||
"test",
|
||||
"test.git",
|
||||
"Test123.git",
|
||||
"Test123-git",
|
||||
"Test_user-123.git",
|
||||
"test@scm-manager.de",
|
||||
"test 123",
|
||||
"tt",
|
||||
"t"
|
||||
];
|
||||
for (let name of validNames) {
|
||||
expect(validator.isNameValid(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("test mail validation", () => {
|
||||
it("should return false", () => {
|
||||
// invalid taken from ValidationUtilTest.java
|
||||
const invalid = [
|
||||
"ostfalia.de",
|
||||
"@ostfalia.de",
|
||||
"s.sdorra@",
|
||||
"s.sdorra@ostfalia",
|
||||
"s.sdorra@@ostfalia.de",
|
||||
"s.sdorra@ ostfalia.de",
|
||||
"s.sdorra @ostfalia.de"
|
||||
];
|
||||
for (let mail of invalid) {
|
||||
expect(validator.isMailValid(mail)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return true", () => {
|
||||
// valid taken from ValidationUtilTest.java
|
||||
const valid = [
|
||||
"s.sdorra@ostfalia.de",
|
||||
"sdorra@ostfalia.de",
|
||||
"s.sdorra@hbk-bs.de",
|
||||
"s.sdorra@gmail.com",
|
||||
"s.sdorra@t.co",
|
||||
"s.sdorra@ucla.college",
|
||||
"s.sdorra@example.xn--p1ai",
|
||||
"s.sdorra@scm.solutions"
|
||||
];
|
||||
for (let mail of valid) {
|
||||
expect(validator.isMailValid(mail)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,11 @@
|
||||
margin: 0 !important;
|
||||
padding: 0 0 0 3.8em !important; }
|
||||
|
||||
html, body {
|
||||
background-color: whitesmoke;
|
||||
height: 100%; }
|
||||
.main {
|
||||
min-height: calc(100vh - 260px); }
|
||||
|
||||
.footer {
|
||||
height: 50px; }
|
||||
|
||||
/*! bulma.io v0.7.1 | MIT License | github.com/jgthms/bulma */
|
||||
@keyframes spinAround {
|
||||
@@ -6437,3 +6439,9 @@ label.panel-block {
|
||||
.footer {
|
||||
background-color: #fafafa;
|
||||
padding: 3rem 1.5rem 6rem; }
|
||||
|
||||
.box-link-shadow:hover, .box-link-shadow:focus {
|
||||
box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px #33B2E8; }
|
||||
|
||||
.box-link-shadow:active {
|
||||
box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.2), 0 0 0 1px #33B2E8; }
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../modules/auth";
|
||||
|
||||
import "./App.css";
|
||||
import "font-awesome/css/font-awesome.css";
|
||||
import "../components/modals/ConfirmAlert.css";
|
||||
import { PrimaryNavigation } from "../components/navigation";
|
||||
import Loading from "../components/Loading";
|
||||
|
||||
@@ -26,10 +26,23 @@ $blue: #33B2E8;
|
||||
padding: 0 0 0 3.8em !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: whitesmoke;
|
||||
height: 100%;
|
||||
.main {
|
||||
min-height: calc(100vh - 260px);
|
||||
}
|
||||
.footer {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
// 6. Import the rest of Bulma
|
||||
@import "bulma/bulma";
|
||||
|
||||
// import at the end, because we need a lot of stuff from bulma/bulma
|
||||
.box-link-shadow {
|
||||
&:hover,
|
||||
&:focus {
|
||||
box-shadow: $box-link-hover-shadow;
|
||||
}
|
||||
&:active {
|
||||
box-shadow: $box-link-active-shadow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ class Login extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="hero has-background-light">
|
||||
<section className="hero">
|
||||
<div className="hero-body">
|
||||
<div className="container has-text-centered">
|
||||
<div className="column is-4 is-offset-4">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
import { Route, withRouter } from "react-router";
|
||||
import { Route, Redirect, withRouter } from "react-router";
|
||||
|
||||
import Repositories from "../repositories/containers/Repositories";
|
||||
import Overview from "../repos/containers/Overview";
|
||||
import Users from "../users/containers/Users";
|
||||
import Login from "../containers/Login";
|
||||
import Logout from "../containers/Logout";
|
||||
@@ -12,6 +12,8 @@ import { Switch } from "react-router-dom";
|
||||
import ProtectedRoute from "../components/ProtectedRoute";
|
||||
import AddUser from "../users/containers/AddUser";
|
||||
import SingleUser from "../users/containers/SingleUser";
|
||||
import RepositoryRoot from "../repos/containers/RepositoryRoot";
|
||||
import Create from "../repos/containers/Create";
|
||||
|
||||
type Props = {
|
||||
authenticated?: boolean
|
||||
@@ -21,16 +23,34 @@ class Main extends React.Component<Props> {
|
||||
render() {
|
||||
const { authenticated } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<div className="main">
|
||||
<Switch>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/"
|
||||
component={Repositories}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<Redirect exact path="/" to="/repos" />
|
||||
<Route exact path="/login" component={Login} />
|
||||
<Route path="/logout" component={Logout} />
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/repos"
|
||||
component={Overview}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/repos/create"
|
||||
component={Create}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/repos/:page"
|
||||
component={Overview}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/repo/:namespace/:name"
|
||||
component={RepositoryRoot}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/users"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { createStore, compose, applyMiddleware, combineReducers } from "redux";
|
||||
import { routerReducer, routerMiddleware } from "react-router-redux";
|
||||
|
||||
import users from "./users/modules/users";
|
||||
import repos from "./repos/modules/repos";
|
||||
import repositoryTypes from "./repos/modules/repositoryTypes";
|
||||
import auth from "./modules/auth";
|
||||
import pending from "./modules/pending";
|
||||
import failure from "./modules/failure";
|
||||
@@ -20,6 +22,8 @@ function createReduxStore(history: BrowserHistory) {
|
||||
pending,
|
||||
failure,
|
||||
users,
|
||||
repos,
|
||||
repositoryTypes,
|
||||
auth
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { reactI18nextModule } from "react-i18next";
|
||||
|
||||
const loadPath = process.env.PUBLIC_URL + "/locales/{{lng}}/{{ns}}.json";
|
||||
|
||||
// TODO load locales for moment
|
||||
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
|
||||
59
scm-ui/src/repos/components/DeleteNavAction.js
Normal file
59
scm-ui/src/repos/components/DeleteNavAction.js
Normal file
@@ -0,0 +1,59 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { confirmAlert } from "../../components/modals/ConfirmAlert";
|
||||
import { NavAction } from "../../components/navigation";
|
||||
import type { Repository } from "../types/Repositories";
|
||||
|
||||
type Props = {
|
||||
repository: Repository,
|
||||
confirmDialog?: boolean,
|
||||
delete: Repository => void,
|
||||
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class DeleteNavAction extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
confirmDialog: true
|
||||
};
|
||||
|
||||
delete = () => {
|
||||
this.props.delete(this.props.repository);
|
||||
};
|
||||
|
||||
confirmDelete = () => {
|
||||
const { t } = this.props;
|
||||
confirmAlert({
|
||||
title: t("delete-nav-action.confirm-alert.title"),
|
||||
message: t("delete-nav-action.confirm-alert.message"),
|
||||
buttons: [
|
||||
{
|
||||
label: t("delete-nav-action.confirm-alert.submit"),
|
||||
onClick: () => this.delete()
|
||||
},
|
||||
{
|
||||
label: t("delete-nav-action.confirm-alert.cancel"),
|
||||
onClick: () => null
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
isDeletable = () => {
|
||||
return this.props.repository._links.delete;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { confirmDialog, t } = this.props;
|
||||
const action = confirmDialog ? this.confirmDelete : this.delete();
|
||||
|
||||
if (!this.isDeletable()) {
|
||||
return null;
|
||||
}
|
||||
return <NavAction label={t("delete-nav-action.label")} action={action} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("repos")(DeleteNavAction);
|
||||
79
scm-ui/src/repos/components/DeleteNavAction.test.js
Normal file
79
scm-ui/src/repos/components/DeleteNavAction.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import "../../tests/enzyme";
|
||||
import "../../tests/i18n";
|
||||
import DeleteNavAction from "./DeleteNavAction";
|
||||
|
||||
import { confirmAlert } from "../../components/modals/ConfirmAlert";
|
||||
jest.mock("../../components/modals/ConfirmAlert");
|
||||
|
||||
describe("DeleteNavAction", () => {
|
||||
it("should render nothing, if the delete link is missing", () => {
|
||||
const repository = {
|
||||
_links: {}
|
||||
};
|
||||
|
||||
const navLink = shallow(
|
||||
<DeleteNavAction repository={repository} delete={() => {}} />
|
||||
);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should render the navLink", () => {
|
||||
const repository = {
|
||||
_links: {
|
||||
delete: {
|
||||
href: "/repositories"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = mount(
|
||||
<DeleteNavAction repository={repository} delete={() => {}} />
|
||||
);
|
||||
expect(navLink.text()).not.toBe("");
|
||||
});
|
||||
|
||||
it("should open the confirm dialog on navLink click", () => {
|
||||
const repository = {
|
||||
_links: {
|
||||
delete: {
|
||||
href: "/repositorys"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = mount(
|
||||
<DeleteNavAction repository={repository} delete={() => {}} />
|
||||
);
|
||||
navLink.find("a").simulate("click");
|
||||
|
||||
expect(confirmAlert.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the delete repository function with delete url", () => {
|
||||
const repository = {
|
||||
_links: {
|
||||
delete: {
|
||||
href: "/repos"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let calledUrl = null;
|
||||
function capture(repository) {
|
||||
calledUrl = repository._links.delete.href;
|
||||
}
|
||||
|
||||
const navLink = mount(
|
||||
<DeleteNavAction
|
||||
repository={repository}
|
||||
confirmDialog={false}
|
||||
delete={capture}
|
||||
/>
|
||||
);
|
||||
navLink.find("a").simulate("click");
|
||||
|
||||
expect(calledUrl).toBe("/repos");
|
||||
});
|
||||
});
|
||||
22
scm-ui/src/repos/components/EditNavLink.js
Normal file
22
scm-ui/src/repos/components/EditNavLink.js
Normal file
@@ -0,0 +1,22 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { NavLink } from "../../components/navigation";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Repository } from "../types/Repositories";
|
||||
|
||||
type Props = { editUrl: string, t: string => string, repository: Repository };
|
||||
|
||||
class EditNavLink extends React.Component<Props> {
|
||||
isEditable = () => {
|
||||
return this.props.repository._links.update;
|
||||
};
|
||||
render() {
|
||||
if (!this.isEditable()) {
|
||||
return null;
|
||||
}
|
||||
const { editUrl, t } = this.props;
|
||||
return <NavLink to={editUrl} label={t("edit-nav-link.label")} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("repos")(EditNavLink);
|
||||
32
scm-ui/src/repos/components/EditNavLink.test.js
Normal file
32
scm-ui/src/repos/components/EditNavLink.test.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import "../../tests/enzyme";
|
||||
import "../../tests/i18n";
|
||||
import EditNavLink from "./EditNavLink";
|
||||
|
||||
jest.mock("../../components/modals/ConfirmAlert");
|
||||
jest.mock("../../components/navigation/NavLink", () => () => <div>foo</div>);
|
||||
|
||||
describe("EditNavLink", () => {
|
||||
it("should render nothing, if the modify link is missing", () => {
|
||||
const repository = {
|
||||
_links: {}
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditNavLink repository={repository} editUrl="" />);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should render the navLink", () => {
|
||||
const repository = {
|
||||
_links: {
|
||||
update: {
|
||||
href: "/repositories"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = mount(<EditNavLink repository={repository} editUrl="" />);
|
||||
expect(navLink.text()).toBe("foo");
|
||||
});
|
||||
});
|
||||
56
scm-ui/src/repos/components/RepositoryDetails.js
Normal file
56
scm-ui/src/repos/components/RepositoryDetails.js
Normal file
@@ -0,0 +1,56 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Repository } from "../types/Repositories";
|
||||
import MailLink from "../../components/MailLink";
|
||||
import DateFromNow from "../../components/DateFromNow";
|
||||
|
||||
type Props = {
|
||||
repository: Repository,
|
||||
// context props
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class RepositoryDetails extends React.Component<Props> {
|
||||
render() {
|
||||
const { repository, t } = this.props;
|
||||
return (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t("repository.name")}</td>
|
||||
<td>{repository.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("repository.type")}</td>
|
||||
<td>{repository.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("repository.contact")}</td>
|
||||
<td>
|
||||
<MailLink address={repository.contact} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("repository.description")}</td>
|
||||
<td>{repository.description}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("repository.creationDate")}</td>
|
||||
<td>
|
||||
<DateFromNow date={repository.creationDate} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("repository.lastModified")}</td>
|
||||
<td>
|
||||
<DateFromNow date={repository.lastModified} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("repos")(RepositoryDetails);
|
||||
168
scm-ui/src/repos/components/form/RepositoryForm.js
Normal file
168
scm-ui/src/repos/components/form/RepositoryForm.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { InputField, Select } from "../../../components/forms/index";
|
||||
import { SubmitButton } from "../../../components/buttons/index";
|
||||
import type { Repository } from "../../types/Repositories";
|
||||
import * as validator from "./repositoryValidation";
|
||||
import type { RepositoryType } from "../../types/RepositoryTypes";
|
||||
import Textarea from "../../../components/forms/Textarea";
|
||||
|
||||
type Props = {
|
||||
submitForm: Repository => void,
|
||||
repository?: Repository,
|
||||
repositoryTypes: RepositoryType[],
|
||||
loading?: boolean,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
type State = {
|
||||
repository: Repository,
|
||||
nameValidationError: boolean,
|
||||
contactValidationError: boolean
|
||||
};
|
||||
|
||||
class RepositoryForm extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
repository: {
|
||||
name: "",
|
||||
namespace: "",
|
||||
type: "",
|
||||
contact: "",
|
||||
description: "",
|
||||
_links: {}
|
||||
},
|
||||
nameValidationError: false,
|
||||
contactValidationError: false,
|
||||
descriptionValidationError: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { repository } = this.props;
|
||||
if (repository) {
|
||||
this.setState({ repository: { ...repository } });
|
||||
}
|
||||
}
|
||||
|
||||
isFalsy(value) {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isValid = () => {
|
||||
const repository = this.state.repository;
|
||||
return !(
|
||||
this.state.nameValidationError ||
|
||||
this.state.contactValidationError ||
|
||||
this.isFalsy(repository.name)
|
||||
);
|
||||
};
|
||||
|
||||
submit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (this.isValid()) {
|
||||
this.props.submitForm(this.state.repository);
|
||||
}
|
||||
};
|
||||
|
||||
isCreateMode = () => {
|
||||
return !this.props.repository;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loading, t } = this.props;
|
||||
const repository = this.state.repository;
|
||||
|
||||
return (
|
||||
<form onSubmit={this.submit}>
|
||||
{this.renderCreateOnlyFields()}
|
||||
<InputField
|
||||
label={t("repository.contact")}
|
||||
onChange={this.handleContactChange}
|
||||
value={repository ? repository.contact : ""}
|
||||
validationError={this.state.contactValidationError}
|
||||
errorMessage={t("validation.contact-invalid")}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={t("repository.description")}
|
||||
onChange={this.handleDescriptionChange}
|
||||
value={repository ? repository.description : ""}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={!this.isValid()}
|
||||
loading={loading}
|
||||
label={t("repository-form.submit")}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
createSelectOptions(repositoryTypes: RepositoryType[]) {
|
||||
return repositoryTypes.map(repositoryType => {
|
||||
return {
|
||||
label: repositoryType.displayName,
|
||||
value: repositoryType.name
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
renderCreateOnlyFields() {
|
||||
if (!this.isCreateMode()) {
|
||||
return null;
|
||||
}
|
||||
const { repositoryTypes, t } = this.props;
|
||||
const repository = this.state.repository;
|
||||
return (
|
||||
<div>
|
||||
<InputField
|
||||
label={t("repository.name")}
|
||||
onChange={this.handleNameChange}
|
||||
value={repository ? repository.name : ""}
|
||||
validationError={this.state.nameValidationError}
|
||||
errorMessage={t("validation.name-invalid")}
|
||||
/>
|
||||
<Select
|
||||
label={t("repository.type")}
|
||||
onChange={this.handleTypeChange}
|
||||
value={repository ? repository.type : ""}
|
||||
options={this.createSelectOptions(repositoryTypes)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleNameChange = (name: string) => {
|
||||
this.setState({
|
||||
nameValidationError: !validator.isNameValid(name),
|
||||
repository: { ...this.state.repository, name }
|
||||
});
|
||||
};
|
||||
|
||||
handleTypeChange = (type: string) => {
|
||||
this.setState({
|
||||
repository: { ...this.state.repository, type }
|
||||
});
|
||||
};
|
||||
|
||||
handleContactChange = (contact: string) => {
|
||||
this.setState({
|
||||
contactValidationError: !validator.isContactValid(contact),
|
||||
repository: { ...this.state.repository, contact }
|
||||
});
|
||||
};
|
||||
|
||||
handleDescriptionChange = (description: string) => {
|
||||
this.setState({
|
||||
repository: { ...this.state.repository, description }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("repos")(RepositoryForm);
|
||||
2
scm-ui/src/repos/components/form/index.js
Normal file
2
scm-ui/src/repos/components/form/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import RepositoryForm from "./RepositoryForm";
|
||||
export default RepositoryForm;
|
||||
10
scm-ui/src/repos/components/form/repositoryValidation.js
Normal file
10
scm-ui/src/repos/components/form/repositoryValidation.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// @flow
|
||||
import * as generalValidator from "../../../components/validation";
|
||||
|
||||
export const isNameValid = (name: string) => {
|
||||
return generalValidator.isNameValid(name);
|
||||
};
|
||||
|
||||
export function isContactValid(mail: string) {
|
||||
return "" === mail || generalValidator.isMailValid(mail);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as validator from "./repositoryValidation";
|
||||
|
||||
describe("repository name validation", () => {
|
||||
// we don't need rich tests, because they are in validation.test.js
|
||||
it("should validate the name", () => {
|
||||
expect(validator.isNameValid("scm-manager")).toBe(true);
|
||||
});
|
||||
|
||||
it("should fail for old nested repository names", () => {
|
||||
// in v2 this is not allowed
|
||||
expect(validator.isNameValid("scm/manager")).toBe(false);
|
||||
expect(validator.isNameValid("scm/ma/nager")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repository contact validation", () => {
|
||||
it("should allow empty contact", () => {
|
||||
expect(validator.isContactValid("")).toBe(true);
|
||||
});
|
||||
|
||||
// we don't need rich tests, because they are in validation.test.js
|
||||
it("should allow real mail addresses", () => {
|
||||
expect(validator.isContactValid("trici.mcmillian@hitchhiker.com")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail on invalid mail addresses", () => {
|
||||
expect(validator.isContactValid("tricia")).toBe(false);
|
||||
});
|
||||
});
|
||||
119
scm-ui/src/repos/components/list/RepositoryEntry.js
Normal file
119
scm-ui/src/repos/components/list/RepositoryEntry.js
Normal file
@@ -0,0 +1,119 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import injectSheet from "react-jss";
|
||||
import type { Repository } from "../../types/Repositories";
|
||||
import DateFromNow from "../../../components/DateFromNow";
|
||||
import RepositoryEntryLink from "./RepositoryEntryLink";
|
||||
import classNames from "classnames";
|
||||
|
||||
import icon from "../../../images/blib.jpg";
|
||||
|
||||
const styles = {
|
||||
outer: {
|
||||
position: "relative"
|
||||
},
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0
|
||||
},
|
||||
inner: {
|
||||
position: "relative",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1
|
||||
},
|
||||
innerLink: {
|
||||
pointerEvents: "all"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
repository: Repository,
|
||||
// context props
|
||||
classes: any
|
||||
};
|
||||
|
||||
class RepositoryEntry extends React.Component<Props> {
|
||||
createLink = (repository: Repository) => {
|
||||
return `/repo/${repository.namespace}/${repository.name}`;
|
||||
};
|
||||
|
||||
renderChangesetsLink = (repository: Repository, repositoryLink: string) => {
|
||||
if (repository._links["changesets"]) {
|
||||
return (
|
||||
<RepositoryEntryLink
|
||||
iconClass="fa-code-fork"
|
||||
to={repositoryLink + "/changesets"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
renderSourcesLink = (repository: Repository, repositoryLink: string) => {
|
||||
if (repository._links["sources"]) {
|
||||
return (
|
||||
<RepositoryEntryLink
|
||||
iconClass="fa-code"
|
||||
to={repositoryLink + "/sources"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
renderModifyLink = (repository: Repository, repositoryLink: string) => {
|
||||
if (repository._links["update"]) {
|
||||
return (
|
||||
<RepositoryEntryLink
|
||||
iconClass="fa-cog"
|
||||
to={repositoryLink + "/modify"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { repository, classes } = this.props;
|
||||
const repositoryLink = this.createLink(repository);
|
||||
return (
|
||||
<div className={classNames("box", "box-link-shadow", classes.outer)}>
|
||||
<Link className={classes.overlay} to={repositoryLink} />
|
||||
<article className={classNames("media", classes.inner)}>
|
||||
<figure className="media-left">
|
||||
<p className="image is-64x64">
|
||||
<img src={icon} alt="Logo" />
|
||||
</p>
|
||||
</figure>
|
||||
<div className="media-content">
|
||||
<div className="content">
|
||||
<p>
|
||||
<strong>{repository.name}</strong>
|
||||
<br />
|
||||
{repository.description}
|
||||
</p>
|
||||
</div>
|
||||
<nav className="level is-mobile">
|
||||
<div className="level-left">
|
||||
{this.renderChangesetsLink(repository, repositoryLink)}
|
||||
{this.renderSourcesLink(repository, repositoryLink)}
|
||||
{this.renderModifyLink(repository, repositoryLink)}
|
||||
</div>
|
||||
<div className="level-right is-hidden-mobile">
|
||||
<small className="level-item">
|
||||
<DateFromNow date={repository.creationDate} />
|
||||
</small>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(RepositoryEntry);
|
||||
34
scm-ui/src/repos/components/list/RepositoryEntryLink.js
Normal file
34
scm-ui/src/repos/components/list/RepositoryEntryLink.js
Normal file
@@ -0,0 +1,34 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import injectSheet from "react-jss";
|
||||
import classNames from "classnames";
|
||||
|
||||
const styles = {
|
||||
link: {
|
||||
pointerEvents: "all"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
to: string,
|
||||
iconClass: string,
|
||||
|
||||
// context props
|
||||
classes: any
|
||||
};
|
||||
|
||||
class RepositoryEntryLink extends React.Component<Props> {
|
||||
render() {
|
||||
const { to, iconClass, classes } = this.props;
|
||||
return (
|
||||
<Link className={classNames("level-item", classes.link)} to={to}>
|
||||
<span className="icon is-small">
|
||||
<i className={classNames("fa", iconClass)} />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(RepositoryEntryLink);
|
||||
67
scm-ui/src/repos/components/list/RepositoryGroupEntry.js
Normal file
67
scm-ui/src/repos/components/list/RepositoryGroupEntry.js
Normal file
@@ -0,0 +1,67 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { RepositoryGroup } from "../../types/Repositories";
|
||||
import injectSheet from "react-jss";
|
||||
import classNames from "classnames";
|
||||
import RepositoryEntry from "./RepositoryEntry";
|
||||
|
||||
const styles = {
|
||||
pointer: {
|
||||
cursor: "pointer"
|
||||
},
|
||||
repoGroup: {
|
||||
marginBottom: "1em"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
group: RepositoryGroup,
|
||||
|
||||
// context props
|
||||
classes: any
|
||||
};
|
||||
|
||||
type State = {
|
||||
collapsed: boolean
|
||||
};
|
||||
|
||||
class RepositoryGroupEntry extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
collapsed: false
|
||||
};
|
||||
}
|
||||
|
||||
toggleCollapse = () => {
|
||||
this.setState(prevState => ({
|
||||
collapsed: !prevState.collapsed
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { group, classes } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
const icon = collapsed ? "fa-angle-right" : "fa-angle-down";
|
||||
let content = null;
|
||||
if (!collapsed) {
|
||||
content = group.repositories.map((repository, index) => {
|
||||
return <RepositoryEntry repository={repository} key={index} />;
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className={classes.repoGroup}>
|
||||
<h2>
|
||||
<span className={classes.pointer} onClick={this.toggleCollapse}>
|
||||
<i className={classNames("fa", icon)} /> {group.name}
|
||||
</span>
|
||||
</h2>
|
||||
<hr />
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(RepositoryGroupEntry);
|
||||
28
scm-ui/src/repos/components/list/RepositoryList.js
Normal file
28
scm-ui/src/repos/components/list/RepositoryList.js
Normal file
@@ -0,0 +1,28 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
import type { Repository } from "../../types/Repositories";
|
||||
|
||||
import groupByNamespace from "./groupByNamespace";
|
||||
import RepositoryGroupEntry from "./RepositoryGroupEntry";
|
||||
|
||||
type Props = {
|
||||
repositories: Repository[]
|
||||
};
|
||||
|
||||
class RepositoryList extends React.Component<Props> {
|
||||
render() {
|
||||
const { repositories } = this.props;
|
||||
|
||||
const groups = groupByNamespace(repositories);
|
||||
return (
|
||||
<div className="content">
|
||||
{groups.map(group => {
|
||||
return <RepositoryGroupEntry group={group} key={group.name} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RepositoryList;
|
||||
39
scm-ui/src/repos/components/list/groupByNamespace.js
Normal file
39
scm-ui/src/repos/components/list/groupByNamespace.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import type { Repository, RepositoryGroup } from "../../types/Repositories";
|
||||
|
||||
export default function groupByNamespace(
|
||||
repositories: Repository[]
|
||||
): RepositoryGroup[] {
|
||||
let groups = {};
|
||||
for (let repository of repositories) {
|
||||
const groupName = repository.namespace;
|
||||
|
||||
let group = groups[groupName];
|
||||
if (!group) {
|
||||
group = {
|
||||
name: groupName,
|
||||
repositories: []
|
||||
};
|
||||
groups[groupName] = group;
|
||||
}
|
||||
group.repositories.push(repository);
|
||||
}
|
||||
|
||||
let groupArray = [];
|
||||
for (let groupName in groups) {
|
||||
const group = groups[groupName];
|
||||
group.repositories.sort(sortByName);
|
||||
groupArray.push(groups[groupName]);
|
||||
}
|
||||
groupArray.sort(sortByName);
|
||||
return groupArray;
|
||||
}
|
||||
|
||||
function sortByName(a, b) {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
} else if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
74
scm-ui/src/repos/components/list/groupByNamespace.test.js
Normal file
74
scm-ui/src/repos/components/list/groupByNamespace.test.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// @flow
|
||||
import groupByNamespace from "./groupByNamespace";
|
||||
|
||||
const base = {
|
||||
type: "git",
|
||||
_links: {}
|
||||
};
|
||||
|
||||
const slartiBlueprintsFjords = {
|
||||
...base,
|
||||
namespace: "slarti",
|
||||
name: "fjords-blueprints"
|
||||
};
|
||||
|
||||
const slartiFjords = {
|
||||
...base,
|
||||
namespace: "slarti",
|
||||
name: "fjords"
|
||||
};
|
||||
|
||||
const hitchhikerRestand = {
|
||||
...base,
|
||||
namespace: "hitchhiker",
|
||||
name: "restand"
|
||||
};
|
||||
const hitchhikerPuzzle42 = {
|
||||
...base,
|
||||
namespace: "hitchhiker",
|
||||
name: "puzzle42"
|
||||
};
|
||||
|
||||
const hitchhikerHeartOfGold = {
|
||||
...base,
|
||||
namespace: "hitchhiker",
|
||||
name: "heartOfGold"
|
||||
};
|
||||
|
||||
const zaphodMarvinFirmware = {
|
||||
...base,
|
||||
namespace: "zaphod",
|
||||
name: "marvin-firmware"
|
||||
};
|
||||
|
||||
it("should group the repositories by their namespace", () => {
|
||||
const repositories = [
|
||||
zaphodMarvinFirmware,
|
||||
slartiBlueprintsFjords,
|
||||
hitchhikerRestand,
|
||||
slartiFjords,
|
||||
hitchhikerHeartOfGold,
|
||||
hitchhikerPuzzle42
|
||||
];
|
||||
|
||||
const expected = [
|
||||
{
|
||||
name: "hitchhiker",
|
||||
repositories: [
|
||||
hitchhikerHeartOfGold,
|
||||
hitchhikerPuzzle42,
|
||||
hitchhikerRestand
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "slarti",
|
||||
repositories: [slartiFjords, slartiBlueprintsFjords]
|
||||
},
|
||||
{
|
||||
name: "zaphod",
|
||||
repositories: [zaphodMarvinFirmware]
|
||||
}
|
||||
];
|
||||
|
||||
expect(groupByNamespace(repositories)).toEqual(expected);
|
||||
});
|
||||
2
scm-ui/src/repos/components/list/index.js
Normal file
2
scm-ui/src/repos/components/list/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import RepositoryList from "./RepositoryList";
|
||||
export default RepositoryList;
|
||||
111
scm-ui/src/repos/containers/Create.js
Normal file
111
scm-ui/src/repos/containers/Create.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import { Page } from "../../components/layout";
|
||||
import RepositoryForm from "../components/form";
|
||||
import type { RepositoryType } from "../types/RepositoryTypes";
|
||||
import {
|
||||
fetchRepositoryTypesIfNeeded,
|
||||
getFetchRepositoryTypesFailure,
|
||||
getRepositoryTypes,
|
||||
isFetchRepositoryTypesPending
|
||||
} from "../modules/repositoryTypes";
|
||||
import {
|
||||
createRepo,
|
||||
createRepoReset,
|
||||
getCreateRepoFailure,
|
||||
isCreateRepoPending
|
||||
} from "../modules/repos";
|
||||
import type { Repository } from "../types/Repositories";
|
||||
import type { History } from "history";
|
||||
|
||||
type Props = {
|
||||
repositoryTypes: RepositoryType[],
|
||||
typesLoading: boolean,
|
||||
createLoading: boolean,
|
||||
error: Error,
|
||||
|
||||
// dispatch functions
|
||||
fetchRepositoryTypesIfNeeded: () => void,
|
||||
createRepo: (Repository, callback: () => void) => void,
|
||||
resetForm: () => void,
|
||||
|
||||
// context props
|
||||
t: string => string,
|
||||
history: History
|
||||
};
|
||||
|
||||
class Create extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.resetForm();
|
||||
this.props.fetchRepositoryTypesIfNeeded();
|
||||
}
|
||||
|
||||
repoCreated = () => {
|
||||
const { history } = this.props;
|
||||
history.push("/repos");
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
typesLoading,
|
||||
createLoading,
|
||||
repositoryTypes,
|
||||
createRepo,
|
||||
error
|
||||
} = this.props;
|
||||
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<Page
|
||||
title={t("create.title")}
|
||||
subtitle={t("create.subtitle")}
|
||||
loading={typesLoading}
|
||||
error={error}
|
||||
showContentOnError={true}
|
||||
>
|
||||
<RepositoryForm
|
||||
repositoryTypes={repositoryTypes}
|
||||
loading={createLoading}
|
||||
submitForm={repo => {
|
||||
createRepo(repo, this.repoCreated);
|
||||
}}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const repositoryTypes = getRepositoryTypes(state);
|
||||
const typesLoading = isFetchRepositoryTypesPending(state);
|
||||
const createLoading = isCreateRepoPending(state);
|
||||
const error =
|
||||
getFetchRepositoryTypesFailure(state) || getCreateRepoFailure(state);
|
||||
return {
|
||||
repositoryTypes,
|
||||
typesLoading,
|
||||
createLoading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchRepositoryTypesIfNeeded: () => {
|
||||
dispatch(fetchRepositoryTypesIfNeeded());
|
||||
},
|
||||
createRepo: (repository: Repository, callback: () => void) => {
|
||||
dispatch(createRepo(repository, callback));
|
||||
},
|
||||
resetForm: () => {
|
||||
dispatch(createRepoReset());
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("repos")(Create));
|
||||
71
scm-ui/src/repos/containers/Edit.js
Normal file
71
scm-ui/src/repos/containers/Edit.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import RepositoryForm from "../components/form";
|
||||
import type { Repository } from "../types/Repositories";
|
||||
import {
|
||||
modifyRepo,
|
||||
isModifyRepoPending,
|
||||
getModifyRepoFailure
|
||||
} from "../modules/repos";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { History } from "history";
|
||||
import ErrorNotification from "../../components/ErrorNotification";
|
||||
|
||||
type Props = {
|
||||
repository: Repository,
|
||||
modifyRepo: (Repository, () => void) => void,
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
|
||||
// context props
|
||||
t: string => string,
|
||||
history: History
|
||||
};
|
||||
|
||||
class Edit extends React.Component<Props> {
|
||||
repoModified = () => {
|
||||
const { history, repository } = this.props;
|
||||
history.push(`/repo/${repository.namespace}/${repository.name}`);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loading, error } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<ErrorNotification error={error} />
|
||||
<RepositoryForm
|
||||
repository={this.props.repository}
|
||||
loading={loading}
|
||||
submitForm={repo => {
|
||||
this.props.modifyRepo(repo, this.repoModified);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const { namespace, name } = ownProps.repository;
|
||||
const loading = isModifyRepoPending(state, namespace, name);
|
||||
const error = getModifyRepoFailure(state, namespace, name);
|
||||
return {
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
modifyRepo: (repo: Repository, callback: () => void) => {
|
||||
dispatch(modifyRepo(repo, callback));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("repos")(withRouter(Edit)));
|
||||
143
scm-ui/src/repos/containers/Overview.js
Normal file
143
scm-ui/src/repos/containers/Overview.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
|
||||
import type { RepositoryCollection } from "../types/Repositories";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
fetchRepos,
|
||||
fetchReposByLink,
|
||||
fetchReposByPage,
|
||||
getFetchReposFailure,
|
||||
getRepositoryCollection,
|
||||
isAbleToCreateRepos,
|
||||
isFetchReposPending
|
||||
} from "../modules/repos";
|
||||
import { translate } from "react-i18next";
|
||||
import { Page } from "../../components/layout";
|
||||
import RepositoryList from "../components/list";
|
||||
import Paginator from "../../components/Paginator";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { History } from "history";
|
||||
import CreateButton from "../../components/buttons/CreateButton";
|
||||
|
||||
type Props = {
|
||||
page: number,
|
||||
collection: RepositoryCollection,
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
showCreateButton: boolean,
|
||||
|
||||
// dispatched functions
|
||||
fetchRepos: () => void,
|
||||
fetchReposByPage: number => void,
|
||||
fetchReposByLink: string => void,
|
||||
|
||||
// context props
|
||||
t: string => string,
|
||||
history: History
|
||||
};
|
||||
|
||||
class Overview extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.fetchReposByPage(this.props.page);
|
||||
}
|
||||
|
||||
/**
|
||||
* reflect page transitions in the uri
|
||||
*/
|
||||
componentDidUpdate() {
|
||||
const { page, collection } = this.props;
|
||||
if (collection) {
|
||||
// backend starts paging by 0
|
||||
const statePage: number = collection.page + 1;
|
||||
if (page !== statePage) {
|
||||
this.props.history.push(`/repos/${statePage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, loading, t } = this.props;
|
||||
return (
|
||||
<Page
|
||||
title={t("overview.title")}
|
||||
subtitle={t("overview.subtitle")}
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
{this.renderList()}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const { collection, fetchReposByLink } = this.props;
|
||||
if (collection) {
|
||||
return (
|
||||
<div>
|
||||
<RepositoryList repositories={collection._embedded.repositories} />
|
||||
<Paginator collection={collection} onPageChange={fetchReposByLink} />
|
||||
{this.renderCreateButton()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
renderCreateButton() {
|
||||
const { showCreateButton, t } = this.props;
|
||||
if (showCreateButton) {
|
||||
return (
|
||||
<CreateButton
|
||||
label={t("overview.create-button")}
|
||||
link="/repos/create"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const getPageFromProps = props => {
|
||||
let page = props.match.params.page;
|
||||
if (page) {
|
||||
page = parseInt(page, 10);
|
||||
} else {
|
||||
page = 1;
|
||||
}
|
||||
return page;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const page = getPageFromProps(ownProps);
|
||||
const collection = getRepositoryCollection(state);
|
||||
const loading = isFetchReposPending(state);
|
||||
const error = getFetchReposFailure(state);
|
||||
const showCreateButton = isAbleToCreateRepos(state);
|
||||
return {
|
||||
page,
|
||||
collection,
|
||||
loading,
|
||||
error,
|
||||
showCreateButton
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchRepos: () => {
|
||||
dispatch(fetchRepos());
|
||||
},
|
||||
fetchReposByPage: (page: number) => {
|
||||
dispatch(fetchReposByPage(page));
|
||||
},
|
||||
fetchReposByLink: (link: string) => {
|
||||
dispatch(fetchReposByLink(link));
|
||||
}
|
||||
};
|
||||
};
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("repos")(withRouter(Overview)));
|
||||
147
scm-ui/src/repos/containers/RepositoryRoot.js
Normal file
147
scm-ui/src/repos/containers/RepositoryRoot.js
Normal file
@@ -0,0 +1,147 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import {
|
||||
deleteRepo,
|
||||
fetchRepo,
|
||||
getFetchRepoFailure,
|
||||
getRepository,
|
||||
isFetchRepoPending
|
||||
} from "../modules/repos";
|
||||
import { connect } from "react-redux";
|
||||
import { Route } from "react-router-dom";
|
||||
import type { Repository } from "../types/Repositories";
|
||||
import { Page } from "../../components/layout";
|
||||
import Loading from "../../components/Loading";
|
||||
import ErrorPage from "../../components/ErrorPage";
|
||||
import { translate } from "react-i18next";
|
||||
import { Navigation, NavLink, Section } from "../../components/navigation";
|
||||
import RepositoryDetails from "../components/RepositoryDetails";
|
||||
import DeleteNavAction from "../components/DeleteNavAction";
|
||||
import Edit from "../containers/Edit";
|
||||
|
||||
import type { History } from "history";
|
||||
import EditNavLink from "../components/EditNavLink";
|
||||
|
||||
type Props = {
|
||||
namespace: string,
|
||||
name: string,
|
||||
repository: Repository,
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
|
||||
// dispatch functions
|
||||
fetchRepo: (namespace: string, name: string) => void,
|
||||
deleteRepo: (repository: Repository, () => void) => void,
|
||||
|
||||
// context props
|
||||
t: string => string,
|
||||
history: History,
|
||||
match: any
|
||||
};
|
||||
|
||||
class RepositoryRoot extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
const { fetchRepo, namespace, name } = this.props;
|
||||
|
||||
fetchRepo(namespace, name);
|
||||
}
|
||||
|
||||
stripEndingSlash = (url: string) => {
|
||||
if (url.endsWith("/")) {
|
||||
return url.substring(0, url.length - 2);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
matchedUrl = () => {
|
||||
return this.stripEndingSlash(this.props.match.url);
|
||||
};
|
||||
|
||||
deleted = () => {
|
||||
this.props.history.push("/repos");
|
||||
};
|
||||
|
||||
delete = (repository: Repository) => {
|
||||
this.props.deleteRepo(repository, this.deleted);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loading, error, repository, t } = this.props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t("repository-root.error-title")}
|
||||
subtitle={t("repository-root.error-subtitle")}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!repository || loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const url = this.matchedUrl();
|
||||
|
||||
return (
|
||||
<Page title={repository.namespace + "/" + repository.name}>
|
||||
<div className="columns">
|
||||
<div className="column is-three-quarters">
|
||||
<Route
|
||||
path={url}
|
||||
exact
|
||||
component={() => <RepositoryDetails repository={repository} />}
|
||||
/>
|
||||
<Route
|
||||
path={`${url}/edit`}
|
||||
component={() => <Edit repository={repository} />}
|
||||
/>
|
||||
</div>
|
||||
<div className="column">
|
||||
<Navigation>
|
||||
<Section label={t("repository-root.navigation-label")}>
|
||||
<NavLink to={url} label={t("repository-root.information")} />
|
||||
<EditNavLink repository={repository} editUrl={`${url}/edit`} />
|
||||
</Section>
|
||||
<Section label={t("repository-root.actions-label")}>
|
||||
<DeleteNavAction repository={repository} delete={this.delete} />
|
||||
<NavLink to="/repos" label={t("repository-root.back-label")} />
|
||||
</Section>
|
||||
</Navigation>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const { namespace, name } = ownProps.match.params;
|
||||
const repository = getRepository(state, namespace, name);
|
||||
const loading = isFetchRepoPending(state, namespace, name);
|
||||
const error = getFetchRepoFailure(state, namespace, name);
|
||||
return {
|
||||
namespace,
|
||||
name,
|
||||
repository,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchRepo: (namespace: string, name: string) => {
|
||||
dispatch(fetchRepo(namespace, name));
|
||||
},
|
||||
deleteRepo: (repository: Repository, callback: () => void) => {
|
||||
dispatch(deleteRepo(repository, callback));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("repos")(RepositoryRoot));
|
||||
447
scm-ui/src/repos/modules/repos.js
Normal file
447
scm-ui/src/repos/modules/repos.js
Normal file
@@ -0,0 +1,447 @@
|
||||
// @flow
|
||||
import { apiClient } from "../../apiclient";
|
||||
import * as types from "../../modules/types";
|
||||
import type { Action } from "../../types/Action";
|
||||
import type { Repository, RepositoryCollection } from "../types/Repositories";
|
||||
import { isPending } from "../../modules/pending";
|
||||
import { getFailure } from "../../modules/failure";
|
||||
|
||||
export const FETCH_REPOS = "scm/repos/FETCH_REPOS";
|
||||
export const FETCH_REPOS_PENDING = `${FETCH_REPOS}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_REPOS_SUCCESS = `${FETCH_REPOS}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_REPOS_FAILURE = `${FETCH_REPOS}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const FETCH_REPO = "scm/repos/FETCH_REPO";
|
||||
export const FETCH_REPO_PENDING = `${FETCH_REPO}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_REPO_SUCCESS = `${FETCH_REPO}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_REPO_FAILURE = `${FETCH_REPO}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const CREATE_REPO = "scm/repos/CREATE_REPO";
|
||||
export const CREATE_REPO_PENDING = `${CREATE_REPO}_${types.PENDING_SUFFIX}`;
|
||||
export const CREATE_REPO_SUCCESS = `${CREATE_REPO}_${types.SUCCESS_SUFFIX}`;
|
||||
export const CREATE_REPO_FAILURE = `${CREATE_REPO}_${types.FAILURE_SUFFIX}`;
|
||||
export const CREATE_REPO_RESET = `${CREATE_REPO}_${types.RESET_SUFFIX}`;
|
||||
|
||||
export const MODIFY_REPO = "scm/repos/MODIFY_REPO";
|
||||
export const MODIFY_REPO_PENDING = `${MODIFY_REPO}_${types.PENDING_SUFFIX}`;
|
||||
export const MODIFY_REPO_SUCCESS = `${MODIFY_REPO}_${types.SUCCESS_SUFFIX}`;
|
||||
export const MODIFY_REPO_FAILURE = `${MODIFY_REPO}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const DELETE_REPO = "scm/repos/DELETE_REPO";
|
||||
export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`;
|
||||
export const DELETE_REPO_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`;
|
||||
export const DELETE_REPO_FAILURE = `${DELETE_REPO}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
const REPOS_URL = "repositories";
|
||||
|
||||
const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2";
|
||||
|
||||
// fetch repos
|
||||
|
||||
const SORT_BY = "sortBy=namespaceAndName";
|
||||
|
||||
export function fetchRepos() {
|
||||
return fetchReposByLink(REPOS_URL);
|
||||
}
|
||||
|
||||
export function fetchReposByPage(page: number) {
|
||||
return fetchReposByLink(`${REPOS_URL}?page=${page - 1}`);
|
||||
}
|
||||
|
||||
function appendSortByLink(url: string) {
|
||||
if (url.includes(SORT_BY)) {
|
||||
return url;
|
||||
}
|
||||
let urlWithSortBy = url;
|
||||
if (url.includes("?")) {
|
||||
urlWithSortBy += "&";
|
||||
} else {
|
||||
urlWithSortBy += "?";
|
||||
}
|
||||
return urlWithSortBy + SORT_BY;
|
||||
}
|
||||
|
||||
export function fetchReposByLink(link: string) {
|
||||
const url = appendSortByLink(link);
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchReposPending());
|
||||
return apiClient
|
||||
.get(url)
|
||||
.then(response => response.json())
|
||||
.then(repositories => {
|
||||
dispatch(fetchReposSuccess(repositories));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(fetchReposFailure(err));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReposPending(): Action {
|
||||
return {
|
||||
type: FETCH_REPOS_PENDING
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReposSuccess(repositories: RepositoryCollection): Action {
|
||||
return {
|
||||
type: FETCH_REPOS_SUCCESS,
|
||||
payload: repositories
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReposFailure(err: Error): Action {
|
||||
return {
|
||||
type: FETCH_REPOS_FAILURE,
|
||||
payload: err
|
||||
};
|
||||
}
|
||||
|
||||
// fetch repo
|
||||
|
||||
export function fetchRepo(namespace: string, name: string) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchRepoPending(namespace, name));
|
||||
return apiClient
|
||||
.get(`${REPOS_URL}/${namespace}/${name}`)
|
||||
.then(response => response.json())
|
||||
.then(repository => {
|
||||
dispatch(fetchRepoSuccess(repository));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(fetchRepoFailure(namespace, name, err));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchRepoPending(namespace: string, name: string): Action {
|
||||
return {
|
||||
type: FETCH_REPO_PENDING,
|
||||
payload: {
|
||||
namespace,
|
||||
name
|
||||
},
|
||||
itemId: namespace + "/" + name
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchRepoSuccess(repository: Repository): Action {
|
||||
return {
|
||||
type: FETCH_REPO_SUCCESS,
|
||||
payload: repository,
|
||||
itemId: createIdentifier(repository)
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchRepoFailure(
|
||||
namespace: string,
|
||||
name: string,
|
||||
error: Error
|
||||
): Action {
|
||||
return {
|
||||
type: FETCH_REPO_FAILURE,
|
||||
payload: {
|
||||
namespace,
|
||||
name,
|
||||
error
|
||||
},
|
||||
itemId: namespace + "/" + name
|
||||
};
|
||||
}
|
||||
|
||||
// create repo
|
||||
|
||||
export function createRepo(repository: Repository, callback?: () => void) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(createRepoPending());
|
||||
return apiClient
|
||||
.post(REPOS_URL, repository, CONTENT_TYPE)
|
||||
.then(() => {
|
||||
dispatch(createRepoSuccess());
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(createRepoFailure(err));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createRepoPending(): Action {
|
||||
return {
|
||||
type: CREATE_REPO_PENDING
|
||||
};
|
||||
}
|
||||
|
||||
export function createRepoSuccess(): Action {
|
||||
return {
|
||||
type: CREATE_REPO_SUCCESS
|
||||
};
|
||||
}
|
||||
|
||||
export function createRepoFailure(err: Error): Action {
|
||||
return {
|
||||
type: CREATE_REPO_FAILURE,
|
||||
payload: err
|
||||
};
|
||||
}
|
||||
|
||||
export function createRepoReset(): Action {
|
||||
return {
|
||||
type: CREATE_REPO_RESET
|
||||
};
|
||||
}
|
||||
|
||||
// modify
|
||||
|
||||
export function modifyRepo(repository: Repository, callback?: () => void) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(modifyRepoPending(repository));
|
||||
|
||||
return apiClient
|
||||
.put(repository._links.update.href, repository, CONTENT_TYPE)
|
||||
.then(() => {
|
||||
dispatch(modifyRepoSuccess(repository));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(cause => {
|
||||
const error = new Error(`failed to modify repo: ${cause.message}`);
|
||||
dispatch(modifyRepoFailure(repository, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyRepoPending(repository: Repository): Action {
|
||||
return {
|
||||
type: MODIFY_REPO_PENDING,
|
||||
payload: repository,
|
||||
itemId: createIdentifier(repository)
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyRepoSuccess(repository: Repository): Action {
|
||||
return {
|
||||
type: MODIFY_REPO_SUCCESS,
|
||||
payload: repository,
|
||||
itemId: createIdentifier(repository)
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyRepoFailure(
|
||||
repository: Repository,
|
||||
error: Error
|
||||
): Action {
|
||||
return {
|
||||
type: MODIFY_REPO_FAILURE,
|
||||
payload: { error, repository },
|
||||
itemId: createIdentifier(repository)
|
||||
};
|
||||
}
|
||||
|
||||
// delete
|
||||
|
||||
export function deleteRepo(repository: Repository, callback?: () => void) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(deleteRepoPending(repository));
|
||||
return apiClient
|
||||
.delete(repository._links.delete.href)
|
||||
.then(() => {
|
||||
dispatch(deleteRepoSuccess(repository));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(deleteRepoFailure(repository, err));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteRepoPending(repository: Repository): Action {
|
||||
return {
|
||||
type: DELETE_REPO_PENDING,
|
||||
payload: repository,
|
||||
itemId: createIdentifier(repository)
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteRepoSuccess(repository: Repository): Action {
|
||||
return {
|
||||
type: DELETE_REPO_SUCCESS,
|
||||
payload: repository,
|
||||
itemId: createIdentifier(repository)
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteRepoFailure(
|
||||
repository: Repository,
|
||||
error: Error
|
||||
): Action {
|
||||
return {
|
||||
type: DELETE_REPO_FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
repository
|
||||
},
|
||||
itemId: createIdentifier(repository)
|
||||
};
|
||||
}
|
||||
|
||||
// reducer
|
||||
|
||||
function createIdentifier(repository: Repository) {
|
||||
return repository.namespace + "/" + repository.name;
|
||||
}
|
||||
|
||||
function normalizeByNamespaceAndName(
|
||||
repositoryCollection: RepositoryCollection
|
||||
) {
|
||||
const names = [];
|
||||
const byNames = {};
|
||||
for (const repository of repositoryCollection._embedded.repositories) {
|
||||
const identifier = createIdentifier(repository);
|
||||
names.push(identifier);
|
||||
byNames[identifier] = repository;
|
||||
}
|
||||
return {
|
||||
list: {
|
||||
...repositoryCollection,
|
||||
_embedded: {
|
||||
repositories: names
|
||||
}
|
||||
},
|
||||
byNames: byNames
|
||||
};
|
||||
}
|
||||
|
||||
const reducerByNames = (state: Object, repository: Repository) => {
|
||||
const identifier = createIdentifier(repository);
|
||||
const newState = {
|
||||
...state,
|
||||
byNames: {
|
||||
...state.byNames,
|
||||
[identifier]: repository
|
||||
}
|
||||
};
|
||||
|
||||
return newState;
|
||||
};
|
||||
|
||||
export default function reducer(
|
||||
state: Object = {},
|
||||
action: Action = { type: "UNKNOWN" }
|
||||
): Object {
|
||||
if (!action.payload) {
|
||||
return state;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case FETCH_REPOS_SUCCESS:
|
||||
return normalizeByNamespaceAndName(action.payload);
|
||||
case MODIFY_REPO_SUCCESS:
|
||||
return reducerByNames(state, action.payload);
|
||||
case FETCH_REPO_SUCCESS:
|
||||
return reducerByNames(state, action.payload);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// selectors
|
||||
|
||||
export function getRepositoryCollection(state: Object) {
|
||||
if (state.repos && state.repos.list && state.repos.byNames) {
|
||||
const repositories = [];
|
||||
for (let repositoryName of state.repos.list._embedded.repositories) {
|
||||
repositories.push(state.repos.byNames[repositoryName]);
|
||||
}
|
||||
return {
|
||||
...state.repos.list,
|
||||
_embedded: {
|
||||
repositories
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isFetchReposPending(state: Object) {
|
||||
return isPending(state, FETCH_REPOS);
|
||||
}
|
||||
|
||||
export function getFetchReposFailure(state: Object) {
|
||||
return getFailure(state, FETCH_REPOS);
|
||||
}
|
||||
|
||||
export function getRepository(state: Object, namespace: string, name: string) {
|
||||
if (state.repos && state.repos.byNames) {
|
||||
return state.repos.byNames[namespace + "/" + name];
|
||||
}
|
||||
}
|
||||
|
||||
export function isFetchRepoPending(
|
||||
state: Object,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
return isPending(state, FETCH_REPO, namespace + "/" + name);
|
||||
}
|
||||
|
||||
export function getFetchRepoFailure(
|
||||
state: Object,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
return getFailure(state, FETCH_REPO, namespace + "/" + name);
|
||||
}
|
||||
|
||||
export function isAbleToCreateRepos(state: Object) {
|
||||
return !!(
|
||||
state.repos &&
|
||||
state.repos.list &&
|
||||
state.repos.list._links &&
|
||||
state.repos.list._links.create
|
||||
);
|
||||
}
|
||||
|
||||
export function isCreateRepoPending(state: Object) {
|
||||
return isPending(state, CREATE_REPO);
|
||||
}
|
||||
|
||||
export function getCreateRepoFailure(state: Object) {
|
||||
return getFailure(state, CREATE_REPO);
|
||||
}
|
||||
|
||||
export function isModifyRepoPending(
|
||||
state: Object,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
return isPending(state, MODIFY_REPO, namespace + "/" + name);
|
||||
}
|
||||
|
||||
export function getModifyRepoFailure(
|
||||
state: Object,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
return getFailure(state, MODIFY_REPO, namespace + "/" + name);
|
||||
}
|
||||
|
||||
export function isDeleteRepoPending(
|
||||
state: Object,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
return isPending(state, DELETE_REPO, namespace + "/" + name);
|
||||
}
|
||||
|
||||
export function getDeleteRepoFailure(
|
||||
state: Object,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
return getFailure(state, DELETE_REPO, namespace + "/" + name);
|
||||
}
|
||||
795
scm-ui/src/repos/modules/repos.test.js
Normal file
795
scm-ui/src/repos/modules/repos.test.js
Normal file
@@ -0,0 +1,795 @@
|
||||
// @flow
|
||||
import configureMockStore from "redux-mock-store";
|
||||
import thunk from "redux-thunk";
|
||||
import fetchMock from "fetch-mock";
|
||||
import reducer, {
|
||||
FETCH_REPOS_PENDING,
|
||||
FETCH_REPOS_SUCCESS,
|
||||
fetchRepos,
|
||||
FETCH_REPOS_FAILURE,
|
||||
fetchReposSuccess,
|
||||
getRepositoryCollection,
|
||||
FETCH_REPOS,
|
||||
isFetchReposPending,
|
||||
getFetchReposFailure,
|
||||
fetchReposByLink,
|
||||
fetchReposByPage,
|
||||
FETCH_REPO,
|
||||
fetchRepo,
|
||||
FETCH_REPO_PENDING,
|
||||
FETCH_REPO_SUCCESS,
|
||||
FETCH_REPO_FAILURE,
|
||||
fetchRepoSuccess,
|
||||
getRepository,
|
||||
isFetchRepoPending,
|
||||
getFetchRepoFailure,
|
||||
CREATE_REPO_PENDING,
|
||||
CREATE_REPO_SUCCESS,
|
||||
createRepo,
|
||||
CREATE_REPO_FAILURE,
|
||||
isCreateRepoPending,
|
||||
CREATE_REPO,
|
||||
getCreateRepoFailure,
|
||||
isAbleToCreateRepos,
|
||||
DELETE_REPO,
|
||||
DELETE_REPO_SUCCESS,
|
||||
deleteRepo,
|
||||
DELETE_REPO_PENDING,
|
||||
DELETE_REPO_FAILURE,
|
||||
isDeleteRepoPending,
|
||||
getDeleteRepoFailure,
|
||||
modifyRepo,
|
||||
MODIFY_REPO_PENDING,
|
||||
MODIFY_REPO_SUCCESS,
|
||||
MODIFY_REPO_FAILURE,
|
||||
MODIFY_REPO,
|
||||
isModifyRepoPending,
|
||||
getModifyRepoFailure,
|
||||
modifyRepoSuccess
|
||||
} from "./repos";
|
||||
import type { Repository, RepositoryCollection } from "../types/Repositories";
|
||||
|
||||
const hitchhikerPuzzle42: Repository = {
|
||||
contact: "fourtytwo@hitchhiker.com",
|
||||
creationDate: "2018-07-31T08:58:45.961Z",
|
||||
description: "the answer to life the universe and everything",
|
||||
namespace: "hitchhiker",
|
||||
name: "puzzle42",
|
||||
type: "svn",
|
||||
_links: {
|
||||
self: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42"
|
||||
},
|
||||
delete: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42"
|
||||
},
|
||||
update: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42"
|
||||
},
|
||||
permissions: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/"
|
||||
},
|
||||
tags: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/tags/"
|
||||
},
|
||||
branches: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/branches/"
|
||||
},
|
||||
changesets: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/changesets/"
|
||||
},
|
||||
sources: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/sources/"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const hitchhikerRestatend: Repository = {
|
||||
contact: "restatend@hitchhiker.com",
|
||||
creationDate: "2018-07-31T08:58:32.803Z",
|
||||
description: "restaurant at the end of the universe",
|
||||
namespace: "hitchhiker",
|
||||
name: "restatend",
|
||||
archived: false,
|
||||
type: "git",
|
||||
_links: {
|
||||
self: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend"
|
||||
},
|
||||
delete: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend"
|
||||
},
|
||||
update: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend"
|
||||
},
|
||||
permissions: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/permissions/"
|
||||
},
|
||||
tags: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/tags/"
|
||||
},
|
||||
branches: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/branches/"
|
||||
},
|
||||
changesets: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/changesets/"
|
||||
},
|
||||
sources: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/sources/"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const slartiFjords: Repository = {
|
||||
contact: "slartibartfast@hitchhiker.com",
|
||||
description: "My award-winning fjords from the Norwegian coast",
|
||||
namespace: "slarti",
|
||||
name: "fjords",
|
||||
type: "hg",
|
||||
creationDate: "2018-07-31T08:59:05.653Z",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords"
|
||||
},
|
||||
delete: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords"
|
||||
},
|
||||
update: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords"
|
||||
},
|
||||
permissions: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/permissions/"
|
||||
},
|
||||
tags: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/tags/"
|
||||
},
|
||||
branches: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/branches/"
|
||||
},
|
||||
changesets: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/changesets/"
|
||||
},
|
||||
sources: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/sources/"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const repositoryCollection: RepositoryCollection = {
|
||||
page: 0,
|
||||
pageTotal: 1,
|
||||
_links: {
|
||||
self: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
|
||||
},
|
||||
first: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
|
||||
},
|
||||
last: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
|
||||
},
|
||||
create: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/repositories/"
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
repositories: [hitchhikerPuzzle42, hitchhikerRestatend, slartiFjords]
|
||||
}
|
||||
};
|
||||
|
||||
const repositoryCollectionWithNames: RepositoryCollection = {
|
||||
page: 0,
|
||||
pageTotal: 1,
|
||||
_links: {
|
||||
self: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
|
||||
},
|
||||
first: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
|
||||
},
|
||||
last: {
|
||||
href:
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
|
||||
},
|
||||
create: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/repositories/"
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
repositories: [
|
||||
"hitchhiker/puzzle42",
|
||||
"hitchhiker/restatend",
|
||||
"slarti/fjords"
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
describe("repos fetch", () => {
|
||||
const REPOS_URL = "/scm/api/rest/v2/repositories";
|
||||
const SORT = "sortBy=namespaceAndName";
|
||||
const REPOS_URL_WITH_SORT = REPOS_URL + "?" + SORT;
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should successfully fetch repos", () => {
|
||||
fetchMock.getOnce(REPOS_URL_WITH_SORT, repositoryCollection);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_REPOS_PENDING },
|
||||
{
|
||||
type: FETCH_REPOS_SUCCESS,
|
||||
payload: repositoryCollection
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchRepos()).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully fetch page 42", () => {
|
||||
const url = REPOS_URL + "?page=42&" + SORT;
|
||||
fetchMock.getOnce(url, repositoryCollection);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_REPOS_PENDING },
|
||||
{
|
||||
type: FETCH_REPOS_SUCCESS,
|
||||
payload: repositoryCollection
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(fetchReposByPage(43)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully fetch repos from link", () => {
|
||||
fetchMock.getOnce(
|
||||
REPOS_URL + "?" + SORT + "&page=42",
|
||||
repositoryCollection
|
||||
);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_REPOS_PENDING },
|
||||
{
|
||||
type: FETCH_REPOS_SUCCESS,
|
||||
payload: repositoryCollection
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store
|
||||
.dispatch(
|
||||
fetchReposByLink("/repositories?sortBy=namespaceAndName&page=42")
|
||||
)
|
||||
.then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should append sortby parameter and successfully fetch repos from link", () => {
|
||||
fetchMock.getOnce(
|
||||
"/scm/api/rest/v2/repositories?one=1&sortBy=namespaceAndName",
|
||||
repositoryCollection
|
||||
);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_REPOS_PENDING },
|
||||
{
|
||||
type: FETCH_REPOS_SUCCESS,
|
||||
payload: repositoryCollection
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(fetchReposByLink("/repositories?one=1")).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch FETCH_REPOS_FAILURE, it the request fails", () => {
|
||||
fetchMock.getOnce(REPOS_URL_WITH_SORT, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchRepos()).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_REPOS_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_REPOS_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully fetch repo slarti/fjords", () => {
|
||||
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords);
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
type: FETCH_REPO_PENDING,
|
||||
payload: {
|
||||
namespace: "slarti",
|
||||
name: "fjords"
|
||||
},
|
||||
itemId: "slarti/fjords"
|
||||
},
|
||||
{
|
||||
type: FETCH_REPO_SUCCESS,
|
||||
payload: slartiFjords,
|
||||
itemId: "slarti/fjords"
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchRepo("slarti", "fjords")).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch FETCH_REPO_FAILURE, it the request for slarti/fjords fails", () => {
|
||||
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchRepo("slarti", "fjords")).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_REPO_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_REPO_FAILURE);
|
||||
expect(actions[1].payload.namespace).toBe("slarti");
|
||||
expect(actions[1].payload.name).toBe("fjords");
|
||||
expect(actions[1].payload.error).toBeDefined();
|
||||
expect(actions[1].itemId).toBe("slarti/fjords");
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully create repo slarti/fjords", () => {
|
||||
fetchMock.postOnce(REPOS_URL, {
|
||||
status: 201
|
||||
});
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
type: CREATE_REPO_PENDING
|
||||
},
|
||||
{
|
||||
type: CREATE_REPO_SUCCESS
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createRepo(slartiFjords)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully create repo slarti/fjords and call the callback", () => {
|
||||
fetchMock.postOnce(REPOS_URL, {
|
||||
status: 201
|
||||
});
|
||||
|
||||
let callMe = "not yet";
|
||||
|
||||
const callback = () => {
|
||||
callMe = "yeah";
|
||||
};
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createRepo(slartiFjords, callback)).then(() => {
|
||||
expect(callMe).toBe("yeah");
|
||||
});
|
||||
});
|
||||
|
||||
it("should disapatch failure if server returns status code 500", () => {
|
||||
fetchMock.postOnce(REPOS_URL, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createRepo(slartiFjords)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(CREATE_REPO_PENDING);
|
||||
expect(actions[1].type).toEqual(CREATE_REPO_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully delete repo slarti/fjords", () => {
|
||||
fetchMock.delete(
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords",
|
||||
{
|
||||
status: 204
|
||||
}
|
||||
);
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
type: DELETE_REPO_PENDING,
|
||||
payload: slartiFjords,
|
||||
itemId: "slarti/fjords"
|
||||
},
|
||||
{
|
||||
type: DELETE_REPO_SUCCESS,
|
||||
payload: slartiFjords,
|
||||
itemId: "slarti/fjords"
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteRepo(slartiFjords)).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully delete repo slarti/fjords and call the callback", () => {
|
||||
fetchMock.delete(
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords",
|
||||
{
|
||||
status: 204
|
||||
}
|
||||
);
|
||||
|
||||
let callMe = "not yet";
|
||||
|
||||
const callback = () => {
|
||||
callMe = "yeah";
|
||||
};
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteRepo(slartiFjords, callback)).then(() => {
|
||||
expect(callMe).toBe("yeah");
|
||||
});
|
||||
});
|
||||
|
||||
it("should disapatch failure on delete, if server returns status code 500", () => {
|
||||
fetchMock.delete(
|
||||
"http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords",
|
||||
{
|
||||
status: 500
|
||||
}
|
||||
);
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteRepo(slartiFjords)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(DELETE_REPO_PENDING);
|
||||
expect(actions[1].type).toEqual(DELETE_REPO_FAILURE);
|
||||
expect(actions[1].payload.repository).toBe(slartiFjords);
|
||||
expect(actions[1].payload.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully modify slarti/fjords repo", () => {
|
||||
fetchMock.putOnce(slartiFjords._links.update.href, {
|
||||
status: 204
|
||||
});
|
||||
|
||||
let editedFjords = { ...slartiFjords };
|
||||
editedFjords.description = "coast of africa";
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(modifyRepo(editedFjords)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully modify slarti/fjords repo and call the callback", () => {
|
||||
fetchMock.putOnce(slartiFjords._links.update.href, {
|
||||
status: 204
|
||||
});
|
||||
|
||||
let editedFjords = { ...slartiFjords };
|
||||
editedFjords.description = "coast of africa";
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
let called = false;
|
||||
const callback = () => {
|
||||
called = true;
|
||||
};
|
||||
|
||||
return store.dispatch(modifyRepo(editedFjords, callback)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS);
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail modifying on HTTP 500", () => {
|
||||
fetchMock.putOnce(slartiFjords._links.update.href, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
let editedFjords = { ...slartiFjords };
|
||||
editedFjords.description = "coast of africa";
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(modifyRepo(editedFjords)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_REPO_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("repos 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 repositories by it's namespace and name on FETCH_REPOS_SUCCESS", () => {
|
||||
const newState = reducer({}, fetchReposSuccess(repositoryCollection));
|
||||
expect(newState.list.page).toBe(0);
|
||||
expect(newState.list.pageTotal).toBe(1);
|
||||
expect(newState.list._embedded.repositories).toEqual([
|
||||
"hitchhiker/puzzle42",
|
||||
"hitchhiker/restatend",
|
||||
"slarti/fjords"
|
||||
]);
|
||||
|
||||
expect(newState.byNames["hitchhiker/puzzle42"]).toBe(hitchhikerPuzzle42);
|
||||
expect(newState.byNames["hitchhiker/restatend"]).toBe(hitchhikerRestatend);
|
||||
expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords);
|
||||
});
|
||||
|
||||
it("should store the repo at byNames", () => {
|
||||
const newState = reducer({}, fetchRepoSuccess(slartiFjords));
|
||||
expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords);
|
||||
});
|
||||
|
||||
it("should update reposByNames", () => {
|
||||
const oldState = {
|
||||
byNames: {
|
||||
"slarti/fjords": slartiFjords
|
||||
}
|
||||
};
|
||||
let slartiFjordsEdited = { ...slartiFjords };
|
||||
slartiFjordsEdited.description = "I bless the rains down in Africa";
|
||||
const newState = reducer(oldState, modifyRepoSuccess(slartiFjordsEdited));
|
||||
expect(newState.byNames["slarti/fjords"]).toEqual(slartiFjordsEdited);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repos selectors", () => {
|
||||
const error = new Error("something goes wrong");
|
||||
|
||||
it("should return the repositories collection", () => {
|
||||
const state = {
|
||||
repos: {
|
||||
list: repositoryCollectionWithNames,
|
||||
byNames: {
|
||||
"hitchhiker/puzzle42": hitchhikerPuzzle42,
|
||||
"hitchhiker/restatend": hitchhikerRestatend,
|
||||
"slarti/fjords": slartiFjords
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const collection = getRepositoryCollection(state);
|
||||
expect(collection).toEqual(repositoryCollection);
|
||||
});
|
||||
|
||||
it("should return true, when fetch repos is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_REPOS]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchReposPending(state)).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch repos is not pending", () => {
|
||||
expect(isFetchReposPending({})).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch repos did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_REPOS]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchReposFailure(state)).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch repos did not fail", () => {
|
||||
expect(getFetchReposFailure({})).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return the repository collection", () => {
|
||||
const state = {
|
||||
repos: {
|
||||
byNames: {
|
||||
"slarti/fjords": slartiFjords
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const repository = getRepository(state, "slarti", "fjords");
|
||||
expect(repository).toEqual(slartiFjords);
|
||||
});
|
||||
|
||||
it("should return true, when fetch repo is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_REPO + "/slarti/fjords"]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchRepoPending(state, "slarti", "fjords")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch repo is not pending", () => {
|
||||
expect(isFetchRepoPending({}, "slarti", "fjords")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch repo did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_REPO + "/slarti/fjords"]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchRepoFailure(state, "slarti", "fjords")).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch repo did not fail", () => {
|
||||
expect(getFetchRepoFailure({}, "slarti", "fjords")).toBe(undefined);
|
||||
});
|
||||
|
||||
// create
|
||||
|
||||
it("should return true, when create repo is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[CREATE_REPO]: true
|
||||
}
|
||||
};
|
||||
expect(isCreateRepoPending(state)).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when create repo is not pending", () => {
|
||||
expect(isCreateRepoPending({})).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when create repo did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[CREATE_REPO]: error
|
||||
}
|
||||
};
|
||||
expect(getCreateRepoFailure(state)).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when create repo did not fail", () => {
|
||||
expect(getCreateRepoFailure({})).toBe(undefined);
|
||||
});
|
||||
|
||||
// modify
|
||||
|
||||
it("should return true, when modify repo is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[MODIFY_REPO + "/slarti/fjords"]: true
|
||||
}
|
||||
};
|
||||
|
||||
expect(isModifyRepoPending(state, "slarti", "fjords")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when modify repo is not pending", () => {
|
||||
expect(isModifyRepoPending({}, "slarti", "fjords")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error, when modify repo failed", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[MODIFY_REPO + "/slarti/fjords"]: error
|
||||
}
|
||||
};
|
||||
|
||||
expect(getModifyRepoFailure(state, "slarti", "fjords")).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined, when modify did not fail", () => {
|
||||
expect(getModifyRepoFailure({}, "slarti", "fjords")).toBeUndefined();
|
||||
});
|
||||
|
||||
// delete
|
||||
|
||||
it("should return true, when delete repo is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[DELETE_REPO + "/slarti/fjords"]: true
|
||||
}
|
||||
};
|
||||
expect(isDeleteRepoPending(state, "slarti", "fjords")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when delete repo is not pending", () => {
|
||||
expect(isDeleteRepoPending({}, "slarti", "fjords")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when delete repo did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[DELETE_REPO + "/slarti/fjords"]: error
|
||||
}
|
||||
};
|
||||
expect(getDeleteRepoFailure(state, "slarti", "fjords")).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when delete repo did not fail", () => {
|
||||
expect(getDeleteRepoFailure({}, "slarti", "fjords")).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return true if the list contains the create link", () => {
|
||||
const state = {
|
||||
repos: {
|
||||
list: repositoryCollection
|
||||
}
|
||||
};
|
||||
|
||||
expect(isAbleToCreateRepos(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false, if create link is unavailable", () => {
|
||||
const state = {
|
||||
repos: {
|
||||
list: {
|
||||
_links: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(isAbleToCreateRepos(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
110
scm-ui/src/repos/modules/repositoryTypes.js
Normal file
110
scm-ui/src/repos/modules/repositoryTypes.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// @flow
|
||||
|
||||
import * as types from "../../modules/types";
|
||||
import type { Action } from "../../types/Action";
|
||||
import type {
|
||||
RepositoryType,
|
||||
RepositoryTypeCollection
|
||||
} from "../types/RepositoryTypes";
|
||||
import { apiClient } from "../../apiclient";
|
||||
import { isPending } from "../../modules/pending";
|
||||
import { getFailure } from "../../modules/failure";
|
||||
|
||||
export const FETCH_REPOSITORY_TYPES = "scm/repos/FETCH_REPOSITORY_TYPES";
|
||||
export const FETCH_REPOSITORY_TYPES_PENDING = `${FETCH_REPOSITORY_TYPES}_${
|
||||
types.PENDING_SUFFIX
|
||||
}`;
|
||||
export const FETCH_REPOSITORY_TYPES_SUCCESS = `${FETCH_REPOSITORY_TYPES}_${
|
||||
types.SUCCESS_SUFFIX
|
||||
}`;
|
||||
export const FETCH_REPOSITORY_TYPES_FAILURE = `${FETCH_REPOSITORY_TYPES}_${
|
||||
types.FAILURE_SUFFIX
|
||||
}`;
|
||||
|
||||
export function fetchRepositoryTypesIfNeeded() {
|
||||
return function(dispatch: any, getState: () => Object) {
|
||||
if (shouldFetchRepositoryTypes(getState())) {
|
||||
return fetchRepositoryTypes(dispatch);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fetchRepositoryTypes(dispatch: any) {
|
||||
dispatch(fetchRepositoryTypesPending());
|
||||
return apiClient
|
||||
.get("repositoryTypes")
|
||||
.then(response => response.json())
|
||||
.then(repositoryTypes => {
|
||||
dispatch(fetchRepositoryTypesSuccess(repositoryTypes));
|
||||
})
|
||||
.catch(err => {
|
||||
const error = new Error(
|
||||
`failed to fetch repository types: ${err.message}`
|
||||
);
|
||||
dispatch(fetchRepositoryTypesFailure(error));
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldFetchRepositoryTypes(state: Object) {
|
||||
if (
|
||||
isFetchRepositoryTypesPending(state) ||
|
||||
getFetchRepositoryTypesFailure(state)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (state.repositoryTypes && state.repositoryTypes.length > 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function fetchRepositoryTypesPending(): Action {
|
||||
return {
|
||||
type: FETCH_REPOSITORY_TYPES_PENDING
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchRepositoryTypesSuccess(
|
||||
repositoryTypes: RepositoryTypeCollection
|
||||
): Action {
|
||||
return {
|
||||
type: FETCH_REPOSITORY_TYPES_SUCCESS,
|
||||
payload: repositoryTypes
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchRepositoryTypesFailure(error: Error): Action {
|
||||
return {
|
||||
type: FETCH_REPOSITORY_TYPES_FAILURE,
|
||||
payload: error
|
||||
};
|
||||
}
|
||||
|
||||
// reducers
|
||||
|
||||
export default function reducer(
|
||||
state: RepositoryType[] = [],
|
||||
action: Action = { type: "UNKNOWN" }
|
||||
): RepositoryType[] {
|
||||
if (action.type === FETCH_REPOSITORY_TYPES_SUCCESS && action.payload) {
|
||||
return action.payload._embedded["repositoryTypes"];
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// selectors
|
||||
|
||||
export function getRepositoryTypes(state: Object) {
|
||||
if (state.repositoryTypes) {
|
||||
return state.repositoryTypes;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function isFetchRepositoryTypesPending(state: Object) {
|
||||
return isPending(state, FETCH_REPOSITORY_TYPES);
|
||||
}
|
||||
|
||||
export function getFetchRepositoryTypesFailure(state: Object) {
|
||||
return getFailure(state, FETCH_REPOSITORY_TYPES);
|
||||
}
|
||||
198
scm-ui/src/repos/modules/repositoryTypes.test.js
Normal file
198
scm-ui/src/repos/modules/repositoryTypes.test.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// @flow
|
||||
|
||||
import fetchMock from "fetch-mock";
|
||||
import configureMockStore from "redux-mock-store";
|
||||
import thunk from "redux-thunk";
|
||||
import {
|
||||
FETCH_REPOSITORY_TYPES,
|
||||
FETCH_REPOSITORY_TYPES_FAILURE,
|
||||
FETCH_REPOSITORY_TYPES_PENDING,
|
||||
FETCH_REPOSITORY_TYPES_SUCCESS,
|
||||
fetchRepositoryTypesIfNeeded,
|
||||
fetchRepositoryTypesSuccess,
|
||||
getFetchRepositoryTypesFailure,
|
||||
getRepositoryTypes,
|
||||
isFetchRepositoryTypesPending,
|
||||
shouldFetchRepositoryTypes
|
||||
} from "./repositoryTypes";
|
||||
import reducer from "./repositoryTypes";
|
||||
|
||||
const git = {
|
||||
name: "git",
|
||||
displayName: "Git",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/repositoryTypes/git"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const hg = {
|
||||
name: "hg",
|
||||
displayName: "Mercurial",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/repositoryTypes/hg"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const svn = {
|
||||
name: "svn",
|
||||
displayName: "Subversion",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/repositoryTypes/svn"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const collection = {
|
||||
_embedded: {
|
||||
repositoryTypes: [git, hg, svn]
|
||||
},
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/repositoryTypes"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("repository types caching", () => {
|
||||
it("should fetch repository types, on empty state", () => {
|
||||
expect(shouldFetchRepositoryTypes({})).toBe(true);
|
||||
});
|
||||
|
||||
it("should fetch repository types, if the state contains an empty array", () => {
|
||||
const state = {
|
||||
repositoryTypes: []
|
||||
};
|
||||
expect(shouldFetchRepositoryTypes(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not fetch repository types, on pending state", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_REPOSITORY_TYPES]: true
|
||||
}
|
||||
};
|
||||
expect(shouldFetchRepositoryTypes(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not fetch repository types, on failure state", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_REPOSITORY_TYPES]: new Error("no...")
|
||||
}
|
||||
};
|
||||
expect(shouldFetchRepositoryTypes(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not fetch repository types, if they are already fetched", () => {
|
||||
const state = {
|
||||
repositoryTypes: [git, hg, svn]
|
||||
};
|
||||
expect(shouldFetchRepositoryTypes(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repository types fetch", () => {
|
||||
const URL = "/scm/api/rest/v2/repositoryTypes";
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should successfully fetch repository types", () => {
|
||||
fetchMock.getOnce(URL, collection);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_REPOSITORY_TYPES_PENDING },
|
||||
{
|
||||
type: FETCH_REPOSITORY_TYPES_SUCCESS,
|
||||
payload: collection
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchRepositoryTypesIfNeeded()).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch FETCH_REPOSITORY_TYPES_FAILURE on server error", () => {
|
||||
fetchMock.getOnce(URL, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchRepositoryTypesIfNeeded()).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toBe(FETCH_REPOSITORY_TYPES_PENDING);
|
||||
expect(actions[1].type).toBe(FETCH_REPOSITORY_TYPES_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch not dispatch any action, if the repository types are already fetched", () => {
|
||||
const store = mockStore({
|
||||
repositoryTypes: [git, hg, svn]
|
||||
});
|
||||
store.dispatch(fetchRepositoryTypesIfNeeded());
|
||||
expect(store.getActions().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repository types reducer", () => {
|
||||
it("should return unmodified state on unknown action", () => {
|
||||
const state = [];
|
||||
expect(reducer(state)).toBe(state);
|
||||
});
|
||||
it("should store the repository types on FETCH_REPOSITORY_TYPES_SUCCESS", () => {
|
||||
const newState = reducer([], fetchRepositoryTypesSuccess(collection));
|
||||
expect(newState).toEqual([git, hg, svn]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repository types selectors", () => {
|
||||
const error = new Error("The end of the universe");
|
||||
|
||||
it("should return an emtpy array", () => {
|
||||
expect(getRepositoryTypes({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return the repository types", () => {
|
||||
const state = {
|
||||
repositoryTypes: [git, hg, svn]
|
||||
};
|
||||
expect(getRepositoryTypes(state)).toEqual([git, hg, svn]);
|
||||
});
|
||||
|
||||
it("should return true, when fetch repository types is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_REPOSITORY_TYPES]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchRepositoryTypesPending(state)).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch repos is not pending", () => {
|
||||
expect(isFetchRepositoryTypesPending({})).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch repository types did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_REPOSITORY_TYPES]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchRepositoryTypesFailure(state)).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch repos did not fail", () => {
|
||||
expect(getFetchRepositoryTypesFailure({})).toBe(undefined);
|
||||
});
|
||||
});
|
||||
25
scm-ui/src/repos/types/Repositories.js
Normal file
25
scm-ui/src/repos/types/Repositories.js
Normal file
@@ -0,0 +1,25 @@
|
||||
//@flow
|
||||
import type { Links } from "../../types/hal";
|
||||
import type { PagedCollection } from "../../types/Collection";
|
||||
|
||||
export type Repository = {
|
||||
namespace: string,
|
||||
name: string,
|
||||
type: string,
|
||||
contact?: string,
|
||||
description?: string,
|
||||
creationDate?: string,
|
||||
lastModified?: string,
|
||||
_links: Links
|
||||
};
|
||||
|
||||
export type RepositoryCollection = PagedCollection & {
|
||||
_embedded: {
|
||||
repositories: Repository[] | string[]
|
||||
}
|
||||
};
|
||||
|
||||
export type RepositoryGroup = {
|
||||
name: string,
|
||||
repositories: Repository[]
|
||||
};
|
||||
14
scm-ui/src/repos/types/RepositoryTypes.js
Normal file
14
scm-ui/src/repos/types/RepositoryTypes.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// @flow
|
||||
|
||||
import type { Collection } from "../../types/Collection";
|
||||
|
||||
export type RepositoryType = {
|
||||
name: string,
|
||||
displayName: string
|
||||
};
|
||||
|
||||
export type RepositoryTypeCollection = Collection & {
|
||||
_embedded: {
|
||||
"repositoryTypes": RepositoryType[]
|
||||
}
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { Page } from "../../components/layout";
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class Repositories extends React.Component<Props> {
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<Page
|
||||
title={t("repositories.title")}
|
||||
subtitle={t("repositories.subtitle")}
|
||||
>
|
||||
{t("repositories.body")}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("repositories")(Repositories);
|
||||
@@ -4,7 +4,8 @@ import { translate } from "react-i18next";
|
||||
import type { User } from "../types/User";
|
||||
import { Checkbox, InputField } from "../../components/forms";
|
||||
import { SubmitButton } from "../../components/buttons";
|
||||
import * as validator from "./userValidation";
|
||||
import * as validator from "../../components/validation";
|
||||
import * as userValidator from "./userValidation";
|
||||
|
||||
type Props = {
|
||||
submitForm: User => void,
|
||||
@@ -157,7 +158,9 @@ class UserForm extends React.Component<Props, State> {
|
||||
|
||||
handleDisplayNameChange = (displayName: string) => {
|
||||
this.setState({
|
||||
displayNameValidationError: !validator.isDisplayNameValid(displayName),
|
||||
displayNameValidationError: !userValidator.isDisplayNameValid(
|
||||
displayName
|
||||
),
|
||||
user: { ...this.state.user, displayName }
|
||||
});
|
||||
};
|
||||
@@ -175,7 +178,7 @@ class UserForm extends React.Component<Props, State> {
|
||||
this.state.validatePassword
|
||||
);
|
||||
this.setState({
|
||||
validatePasswordError: !validator.isPasswordValid(password),
|
||||
validatePasswordError: !userValidator.isPasswordValid(password),
|
||||
passwordValidationError: validatePasswordError,
|
||||
user: { ...this.state.user, password }
|
||||
});
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import injectSheet from "react-jss";
|
||||
import { translate } from "react-i18next";
|
||||
import { AddButton } from "../../../components/buttons";
|
||||
import classNames from "classnames";
|
||||
|
||||
const styles = {
|
||||
spacing: {
|
||||
margin: "1em 0 0 1em"
|
||||
}
|
||||
};
|
||||
import { CreateButton } from "../../../components/buttons";
|
||||
|
||||
// TODO remove
|
||||
type Props = {
|
||||
t: string => string,
|
||||
classes: any
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class CreateUserButton extends React.Component<Props> {
|
||||
render() {
|
||||
const { classes, t } = this.props;
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div className={classNames("is-pulled-right", classes.spacing)}>
|
||||
<AddButton label={t("create-user-button.label")} link="/users/add" />
|
||||
</div>
|
||||
<CreateButton label={t("create-user-button.label")} link="/users/add" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("users")(injectSheet(styles)(CreateUserButton));
|
||||
export default translate("users")(CreateUserButton);
|
||||
|
||||
@@ -3,6 +3,8 @@ import React from "react";
|
||||
import type { User } from "../../types/User";
|
||||
import { translate } from "react-i18next";
|
||||
import { Checkbox } from "../../../components/forms";
|
||||
import MailLink from "../../../components/MailLink";
|
||||
import DateFromNow from "../../../components/DateFromNow";
|
||||
|
||||
type Props = {
|
||||
user: User,
|
||||
@@ -25,7 +27,9 @@ class Details extends React.Component<Props> {
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("user.mail")}</td>
|
||||
<td>{user.mail}</td>
|
||||
<td>
|
||||
<MailLink address={user.mail} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("user.admin")}</td>
|
||||
@@ -39,6 +43,22 @@ class Details extends React.Component<Props> {
|
||||
<Checkbox checked={user.active} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("user.type")}</td>
|
||||
<td>{user.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("user.creationDate")}</td>
|
||||
<td>
|
||||
<DateFromNow date={user.creationDate} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("user.lastModified")}</td>
|
||||
<td>
|
||||
<DateFromNow date={user.lastModified} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
// @flow
|
||||
|
||||
const nameRegex = /^([A-z0-9.\-_@]|[^ ]([A-z0-9.\-_@ ]*[A-z0-9.\-_@]|[^\s])?)$/;
|
||||
|
||||
export const isNameValid = (name: string) => {
|
||||
return nameRegex.test(name);
|
||||
};
|
||||
|
||||
export const isDisplayNameValid = (displayName: string) => {
|
||||
if (displayName) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const mailRegex = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-.]*\.[A-z0-9][A-z0-9-]+$/;
|
||||
|
||||
export const isMailValid = (mail: string) => {
|
||||
return mailRegex.test(mail);
|
||||
};
|
||||
|
||||
export const isPasswordValid = (password: string) => {
|
||||
return password.length > 6 && password.length < 32;
|
||||
};
|
||||
|
||||
@@ -1,45 +1,6 @@
|
||||
// @flow
|
||||
import * as validator from "./userValidation";
|
||||
|
||||
describe("test name validation", () => {
|
||||
it("should return false", () => {
|
||||
// invalid names taken from ValidationUtilTest.java
|
||||
const invalidNames = [
|
||||
" test 123",
|
||||
" test 123 ",
|
||||
"test 123 ",
|
||||
"test/123",
|
||||
"test%123",
|
||||
"test:123",
|
||||
"t ",
|
||||
" t",
|
||||
" t ",
|
||||
""
|
||||
];
|
||||
for (let name of invalidNames) {
|
||||
expect(validator.isNameValid(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return true", () => {
|
||||
// valid names taken from ValidationUtilTest.java
|
||||
const validNames = [
|
||||
"test",
|
||||
"test.git",
|
||||
"Test123.git",
|
||||
"Test123-git",
|
||||
"Test_user-123.git",
|
||||
"test@scm-manager.de",
|
||||
"test 123",
|
||||
"tt",
|
||||
"t"
|
||||
];
|
||||
for (let name of validNames) {
|
||||
expect(validator.isNameValid(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("test displayName validation", () => {
|
||||
it("should return false", () => {
|
||||
expect(validator.isDisplayNameValid("")).toBe(false);
|
||||
@@ -60,41 +21,6 @@ describe("test displayName validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("test mail validation", () => {
|
||||
it("should return false", () => {
|
||||
// invalid taken from ValidationUtilTest.java
|
||||
const invalid = [
|
||||
"ostfalia.de",
|
||||
"@ostfalia.de",
|
||||
"s.sdorra@",
|
||||
"s.sdorra@ostfalia",
|
||||
"s.sdorra@@ostfalia.de",
|
||||
"s.sdorra@ ostfalia.de",
|
||||
"s.sdorra @ostfalia.de"
|
||||
];
|
||||
for (let mail of invalid) {
|
||||
expect(validator.isMailValid(mail)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return true", () => {
|
||||
// valid taken from ValidationUtilTest.java
|
||||
const valid = [
|
||||
"s.sdorra@ostfalia.de",
|
||||
"sdorra@ostfalia.de",
|
||||
"s.sdorra@hbk-bs.de",
|
||||
"s.sdorra@gmail.com",
|
||||
"s.sdorra@t.co",
|
||||
"s.sdorra@ucla.college",
|
||||
"s.sdorra@example.xn--p1ai",
|
||||
"s.sdorra@scm.solutions"
|
||||
];
|
||||
for (let mail of valid) {
|
||||
expect(validator.isMailValid(mail)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("test password validation", () => {
|
||||
it("should return false", () => {
|
||||
// invalid taken from ValidationUtilTest.java
|
||||
|
||||
@@ -48,6 +48,7 @@ class AddUser extends React.Component<Props> {
|
||||
title={t("add-user.title")}
|
||||
subtitle={t("add-user.subtitle")}
|
||||
error={error}
|
||||
showContentOnError={true}
|
||||
>
|
||||
<UserForm
|
||||
submitForm={user => this.createUser(user)}
|
||||
|
||||
@@ -50,9 +50,9 @@ class Users extends React.Component<Props> {
|
||||
/**
|
||||
* reflect page transitions in the uri
|
||||
*/
|
||||
componentDidUpdate = (prevProps: Props) => {
|
||||
componentDidUpdate() {
|
||||
const { page, list } = this.props;
|
||||
if (list.page) {
|
||||
if (list && (list.page || list.page === 0)) {
|
||||
// backend starts paging by 0
|
||||
const statePage: number = list.page + 1;
|
||||
if (page !== statePage) {
|
||||
|
||||
@@ -143,7 +143,7 @@ export function createUser(user: User, callback?: () => void) {
|
||||
return function(dispatch: Dispatch) {
|
||||
dispatch(createUserPending(user));
|
||||
return apiClient
|
||||
.postWithContentType(USERS_URL, user, CONTENT_TYPE_USER)
|
||||
.post(USERS_URL, user, CONTENT_TYPE_USER)
|
||||
.then(() => {
|
||||
dispatch(createUserSuccess());
|
||||
if (callback) {
|
||||
@@ -192,7 +192,7 @@ export function modifyUser(user: User, callback?: () => void) {
|
||||
return function(dispatch: Dispatch) {
|
||||
dispatch(modifyUserPending(user));
|
||||
return apiClient
|
||||
.putWithContentType(user._links.update.href, user, CONTENT_TYPE_USER)
|
||||
.put(user._links.update.href, user, CONTENT_TYPE_USER)
|
||||
.then(() => {
|
||||
dispatch(modifyUserSuccess(user));
|
||||
if (callback) {
|
||||
|
||||
@@ -8,5 +8,8 @@ export type User = {
|
||||
password: string,
|
||||
admin: boolean,
|
||||
active: boolean,
|
||||
type?: string,
|
||||
creationDate?: string,
|
||||
lastModified?: string,
|
||||
_links: Links
|
||||
};
|
||||
|
||||
@@ -3104,6 +3104,10 @@ follow-redirects@^1.0.0:
|
||||
dependencies:
|
||||
debug "^3.1.0"
|
||||
|
||||
font-awesome@^4.7.0:
|
||||
version "4.7.0"
|
||||
resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133"
|
||||
|
||||
for-in@^1.0.1, for-in@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
|
||||
@@ -5216,6 +5220,10 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi
|
||||
dependencies:
|
||||
minimist "0.0.8"
|
||||
|
||||
moment@^2.22.2:
|
||||
version "2.22.2"
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
|
||||
|
||||
ms@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.github.sdorra.ssp.PermissionCheck;
|
||||
import sonia.scm.util.AssertUtil;
|
||||
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public class ManagerDaoAdapter<T extends ModelObject, E extends Exception> {
|
||||
@@ -38,9 +39,13 @@ public class ManagerDaoAdapter<T extends ModelObject, E extends Exception> {
|
||||
}
|
||||
|
||||
public T create(T newObject, Supplier<PermissionCheck> permissionCheck, AroundHandler<T, E> beforeCreate, AroundHandler<T, E> afterCreate) throws E {
|
||||
return create(newObject, permissionCheck, beforeCreate, afterCreate, dao::contains);
|
||||
}
|
||||
|
||||
public T create(T newObject, Supplier<PermissionCheck> permissionCheck, AroundHandler<T, E> beforeCreate, AroundHandler<T, E> afterCreate, Predicate<T> existsCheck) throws E {
|
||||
permissionCheck.get().check();
|
||||
AssertUtil.assertIsValid(newObject);
|
||||
if (dao.contains(newObject)) {
|
||||
if (existsCheck.test(newObject)) {
|
||||
throw alreadyExistsException.apply(newObject);
|
||||
}
|
||||
newObject.setCreationDate(System.currentTimeMillis());
|
||||
|
||||
@@ -374,20 +374,6 @@ public abstract class AbstractManagerResource<T extends ModelObject,
|
||||
throwable.getMessage(), throwable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
*
|
||||
* @param status
|
||||
* @param throwable
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
protected Response createErrorResponse(Status status, Throwable throwable)
|
||||
{
|
||||
return createErrorResponse(status, throwable.getMessage(), throwable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method description
|
||||
*
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import de.otto.edison.hal.HalRepresentation;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static de.otto.edison.hal.Embedded.embeddedBuilder;
|
||||
import static de.otto.edison.hal.Links.linkingTo;
|
||||
|
||||
abstract class CollectionToDtoMapper<E, D extends HalRepresentation> {
|
||||
|
||||
private final String collectionName;
|
||||
private final BaseMapper<E, D> mapper;
|
||||
|
||||
protected CollectionToDtoMapper(String collectionName, BaseMapper<E, D> mapper) {
|
||||
this.collectionName = collectionName;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public HalRepresentation map(Collection<E> collection) {
|
||||
List<D> dtos = collection.stream().map(mapper::map).collect(Collectors.toList());
|
||||
return new HalRepresentation(
|
||||
linkingTo().self(createSelfLink()).build(),
|
||||
embeddedBuilder().with(collectionName, dtos).build()
|
||||
);
|
||||
}
|
||||
|
||||
protected abstract String createSelfLink();
|
||||
|
||||
}
|
||||
@@ -16,6 +16,9 @@ import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.Response;
|
||||
|
||||
/**
|
||||
* RESTful Web Service Resource to manage the configuration.
|
||||
*/
|
||||
@Path(ConfigResource.CONFIG_PATH_V2)
|
||||
public class ConfigResource {
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ public class GroupCollectionResource {
|
||||
@GET
|
||||
@Path("")
|
||||
@Produces(VndMediaType.GROUP_COLLECTION)
|
||||
@TypeHint(GroupDto[].class)
|
||||
@TypeHint(CollectionDto.class)
|
||||
@StatusCodes({
|
||||
@ResponseCode(code = 200, condition = "success"),
|
||||
@ResponseCode(code = 400, condition = "\"sortBy\" field unknown"),
|
||||
|
||||
@@ -21,6 +21,9 @@ public class MapperModule extends AbstractModule {
|
||||
bind(RepositoryToRepositoryDtoMapper.class).to(Mappers.getMapper(RepositoryToRepositoryDtoMapper.class).getClass());
|
||||
bind(RepositoryDtoToRepositoryMapper.class).to(Mappers.getMapper(RepositoryDtoToRepositoryMapper.class).getClass());
|
||||
|
||||
bind(RepositoryTypeToRepositoryTypeDtoMapper.class).to(Mappers.getMapper(RepositoryTypeToRepositoryTypeDtoMapper.class).getClass());
|
||||
bind(RepositoryTypeCollectionToDtoMapper.class);
|
||||
|
||||
bind(UriInfoStore.class).in(ServletScopes.REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.UriInfo;
|
||||
|
||||
|
||||
/**
|
||||
* RESTful Web Service Resource to get currently logged in users.
|
||||
*/
|
||||
@Path(MeResource.ME_PATH_V2)
|
||||
public class MeResource {
|
||||
static final String ME_PATH_V2 = "v2/me/";
|
||||
|
||||
@@ -40,7 +40,7 @@ public class RepositoryCollectionResource {
|
||||
@GET
|
||||
@Path("")
|
||||
@Produces(VndMediaType.REPOSITORY_COLLECTION)
|
||||
@TypeHint(RepositoryDto[].class)
|
||||
@TypeHint(CollectionDto.class)
|
||||
@StatusCodes({
|
||||
@ResponseCode(code = 200, condition = "success"),
|
||||
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
|
||||
|
||||
@@ -3,14 +3,22 @@ package sonia.scm.api.v2.resources;
|
||||
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
|
||||
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
|
||||
import com.webcohesion.enunciate.metadata.rs.TypeHint;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryException;
|
||||
import sonia.scm.repository.RepositoryIsNotArchivedException;
|
||||
import sonia.scm.repository.RepositoryManager;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Provider;
|
||||
import javax.ws.rs.*;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.DELETE;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.PUT;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.Response;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Predicate;
|
||||
@@ -40,7 +48,7 @@ public class RepositoryResource {
|
||||
this.dtoToRepositoryMapper = dtoToRepositoryMapper;
|
||||
this.manager = manager;
|
||||
this.repositoryToDtoMapper = repositoryToDtoMapper;
|
||||
this.adapter = new SingleResourceManagerAdapter<>(manager, Repository.class);
|
||||
this.adapter = new SingleResourceManagerAdapter<>(manager, Repository.class, this::handleNotArchived);
|
||||
this.tagRootResource = tagRootResource;
|
||||
this.branchRootResource = branchRootResource;
|
||||
this.changesetRootResource = changesetRootResource;
|
||||
@@ -148,8 +156,16 @@ public class RepositoryResource {
|
||||
return permissionRootResource.get();
|
||||
}
|
||||
|
||||
private Optional<Response> handleNotArchived(Throwable throwable) {
|
||||
if (throwable instanceof RepositoryIsNotArchivedException) {
|
||||
return Optional.of(Response.status(Response.Status.PRECONDITION_FAILED).build());
|
||||
} else {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Supplier<Optional<Repository>> loadBy(String namespace, String name) {
|
||||
return () -> manager.getByNamespace(namespace, name);
|
||||
return () -> Optional.ofNullable(manager.get(new NamespaceAndName(namespace, name)));
|
||||
}
|
||||
|
||||
private Predicate<Repository> nameAndNamespaceStaysTheSame(String namespace, String name) {
|
||||
|
||||
@@ -25,6 +25,7 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
|
||||
@AfterMapping
|
||||
void appendLinks(Repository repository, @MappingTarget RepositoryDto target) {
|
||||
Links.Builder linksBuilder = linkingTo().self(resourceLinks.repository().self(target.getNamespace(), target.getName()));
|
||||
linksBuilder.single(link("httpProtocol", resourceLinks.repository().clone(target.getType(), target.getNamespace(), target.getName())));
|
||||
if (RepositoryPermissions.delete(repository).isPermitted()) {
|
||||
linksBuilder.single(link("delete", resourceLinks.repository().delete(target.getNamespace(), target.getName())));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
|
||||
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
|
||||
import de.otto.edison.hal.HalRepresentation;
|
||||
import sonia.scm.repository.RepositoryManager;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
|
||||
public class RepositoryTypeCollectionResource {
|
||||
|
||||
private RepositoryManager repositoryManager;
|
||||
private RepositoryTypeCollectionToDtoMapper mapper;
|
||||
|
||||
@Inject
|
||||
public RepositoryTypeCollectionResource(RepositoryManager repositoryManager, RepositoryTypeCollectionToDtoMapper mapper) {
|
||||
this.repositoryManager = repositoryManager;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("")
|
||||
@StatusCodes({
|
||||
@ResponseCode(code = 200, condition = "success"),
|
||||
@ResponseCode(code = 500, condition = "internal server error")
|
||||
})
|
||||
@Produces(VndMediaType.REPOSITORY_TYPE_COLLECTION)
|
||||
public HalRepresentation getAll() {
|
||||
return mapper.map(repositoryManager.getConfiguredTypes());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import sonia.scm.repository.RepositoryType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
public class RepositoryTypeCollectionToDtoMapper extends CollectionToDtoMapper<RepositoryType, RepositoryTypeDto> {
|
||||
|
||||
private final ResourceLinks resourceLinks;
|
||||
|
||||
@Inject
|
||||
public RepositoryTypeCollectionToDtoMapper(RepositoryTypeToRepositoryTypeDtoMapper mapper, ResourceLinks resourceLinks) {
|
||||
super("repositoryTypes", mapper);
|
||||
this.resourceLinks = resourceLinks;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String createSelfLink() {
|
||||
return resourceLinks.repositoryTypeCollection().self();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class RepositoryTypeDto extends HalRepresentation {
|
||||
|
||||
private String name;
|
||||
private String displayName;
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("squid:S1185") // We want to have this method available in this package
|
||||
protected HalRepresentation add(Links links) {
|
||||
return super.add(links);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user