mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-03-04 03:10:50 +01:00
Merge with 2.0.0-m3
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-";
|
||||
@@ -20,8 +21,10 @@ public class VndMediaType {
|
||||
public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX;
|
||||
public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX;
|
||||
public static final String BRANCH_COLLECTION = PREFIX + "branchCollection" + 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
2
scm-ui/.vscode/settings.json
vendored
2
scm-ui/.vscode/settings.json
vendored
@@ -4,7 +4,7 @@
|
||||
"editor.formatOnSave": false,
|
||||
// Enable per-language
|
||||
"[javascript]": {
|
||||
"editor.formatOnSave": true
|
||||
"editor.formatOnSave": false
|
||||
},
|
||||
"flow.pathToFlow": "${workspaceRoot}/node_modules/.bin/flow"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"primary-navigation": {
|
||||
"repositories": "Repositories",
|
||||
"users": "Users",
|
||||
"logout": "Logout"
|
||||
"logout": "Logout",
|
||||
"groups": "Groups"
|
||||
},
|
||||
"paginator": {
|
||||
"next": "Next",
|
||||
|
||||
56
scm-ui/public/locales/en/groups.json
Normal file
56
scm-ui/public/locales/en/groups.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"group": {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"creationDate": "Creation Date",
|
||||
"lastModified": "Last Modified",
|
||||
"type": "Type",
|
||||
"members": "Members"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groups",
|
||||
"subtitle": "Create, read, update and delete groups"
|
||||
},
|
||||
"single-group": {
|
||||
"error-title": "Error",
|
||||
"error-subtitle": "Unknown group error",
|
||||
"navigation-label": "Navigation",
|
||||
"actions-label": "Actions",
|
||||
"information-label": "Information",
|
||||
"back-label": "Back"
|
||||
},
|
||||
"add-group": {
|
||||
"title": "Create Group",
|
||||
"subtitle": "Create a new group"
|
||||
},
|
||||
"create-group-button": {
|
||||
"label": "Create"
|
||||
},
|
||||
"edit-group-button": {
|
||||
"label": "Edit"
|
||||
},
|
||||
"add-member-button": {
|
||||
"label": "Add member"
|
||||
},
|
||||
"remove-member-button": {
|
||||
"label": "Remove member"
|
||||
},
|
||||
"add-member-textfield": {
|
||||
"label": "Add member",
|
||||
"error": "Invalid member name"
|
||||
},
|
||||
"group-form": {
|
||||
"submit": "Submit",
|
||||
"name-error": "Group name is invalid",
|
||||
"description-error": "Description is invalid"
|
||||
},
|
||||
"delete-group-button": {
|
||||
"label": "Delete",
|
||||
"confirm-alert": {
|
||||
"title": "Delete Group",
|
||||
"message": "Do you really want to delete the group?",
|
||||
"submit": "Yes",
|
||||
"cancel": "No"
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -93,8 +93,9 @@ class Paginator extends React.Component<Props> {
|
||||
|
||||
if (page + 1 < pageTotal) {
|
||||
links.push(this.renderPageButton(page + 1, "next"));
|
||||
links.push(this.seperator());
|
||||
}
|
||||
if(page+2 < pageTotal) //if there exists pages between next and last
|
||||
links.push(this.seperator());
|
||||
if (page < pageTotal) {
|
||||
links.push(this.renderLastButton());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
class AddButton extends React.Component<ButtonProps> {
|
||||
render() {
|
||||
return <Button type="default" {...this.props} />;
|
||||
return <Button color="default" {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,19 +7,22 @@ export type ButtonProps = {
|
||||
label: string,
|
||||
loading?: boolean,
|
||||
disabled?: boolean,
|
||||
action?: () => void,
|
||||
action?: (event: Event) => void,
|
||||
link?: string,
|
||||
fullWidth?: boolean,
|
||||
className?: string
|
||||
className?: string,
|
||||
classes: any
|
||||
};
|
||||
|
||||
type Props = ButtonProps & {
|
||||
type: string
|
||||
type: string,
|
||||
color: string
|
||||
};
|
||||
|
||||
class Button extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
type: "default"
|
||||
type: "button",
|
||||
color: "default"
|
||||
};
|
||||
|
||||
renderButton = () => {
|
||||
@@ -28,6 +31,7 @@ class Button extends React.Component<Props> {
|
||||
loading,
|
||||
disabled,
|
||||
type,
|
||||
color,
|
||||
action,
|
||||
fullWidth,
|
||||
className
|
||||
@@ -36,11 +40,12 @@ class Button extends React.Component<Props> {
|
||||
const fullWidthClass = fullWidth ? "is-fullwidth" : "";
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
onClick={action ? action : () => {}}
|
||||
onClick={action ? action : (event: Event) => {}}
|
||||
className={classNames(
|
||||
"button",
|
||||
"is-" + type,
|
||||
"is-" + color,
|
||||
loadingClass,
|
||||
fullWidthClass,
|
||||
className
|
||||
|
||||
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 AddButton, { 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)}>
|
||||
<AddButton {...this.props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectSheet(styles)(CreateButton);
|
||||
@@ -4,7 +4,7 @@ import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
class DeleteButton extends React.Component<ButtonProps> {
|
||||
render() {
|
||||
return <Button type="warning" {...this.props} />;
|
||||
return <Button color="warning" {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
class EditButton extends React.Component<ButtonProps> {
|
||||
render() {
|
||||
return <Button type="default" {...this.props} />;
|
||||
return <Button color="default" {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
class SubmitButton extends React.Component<ButtonProps> {
|
||||
render() {
|
||||
return <Button type="primary" {...this.props} />;
|
||||
return <Button type="submit" color="primary" {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as AddButton } from "./AddButton";
|
||||
export { default as Button } from "./Button";
|
||||
export { default as AddButton } from "./AddButton";
|
||||
export { default as CreateButton } from "./CreateButton";
|
||||
export { default as DeleteButton } from "./DeleteButton";
|
||||
export { default as EditButton } from "./EditButton";
|
||||
export { default as SubmitButton } from "./SubmitButton";
|
||||
|
||||
@@ -9,6 +9,7 @@ type Props = {
|
||||
type?: string,
|
||||
autofocus?: boolean,
|
||||
onChange: string => void,
|
||||
onReturnPressed?: () => void,
|
||||
validationError: boolean,
|
||||
errorMessage: string
|
||||
};
|
||||
@@ -39,6 +40,17 @@ class InputField extends React.Component<Props> {
|
||||
return "";
|
||||
};
|
||||
|
||||
handleKeyPress = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
|
||||
const onReturnPressed = this.props.onReturnPressed;
|
||||
if (!onReturnPressed) {
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
onReturnPressed();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { type, placeholder, value, validationError, errorMessage } = this.props;
|
||||
const errorView = validationError ? "is-danger" : "";
|
||||
@@ -59,6 +71,7 @@ class InputField extends React.Component<Props> {
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={this.handleInput}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
/>
|
||||
</div>
|
||||
{helper}
|
||||
|
||||
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
|
||||
@@ -23,6 +23,11 @@ class PrimaryNavigation extends React.Component<Props> {
|
||||
match="/(user|users)"
|
||||
label={t("primary-navigation.users")}
|
||||
/>
|
||||
<PrimaryNavigationLink
|
||||
to="/groups"
|
||||
match="/(group|groups)"
|
||||
label={t("primary-navigation.groups")}
|
||||
/>
|
||||
<PrimaryNavigationLink
|
||||
to="/logout"
|
||||
label={t("primary-navigation.logout")}
|
||||
|
||||
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);
|
||||
};
|
||||
87
scm-ui/src/components/validation.test.js
Normal file
87
scm-ui/src/components/validation.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// @flow
|
||||
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 ",
|
||||
"",
|
||||
|
||||
" invalid_name",
|
||||
"another%one",
|
||||
"!!!",
|
||||
"!_!"
|
||||
];
|
||||
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",
|
||||
|
||||
"valid_name",
|
||||
"another1",
|
||||
"stillValid",
|
||||
"this.one_as-well",
|
||||
"and@this"
|
||||
];
|
||||
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,12 @@ 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";
|
||||
|
||||
import Groups from "../groups/containers/Groups";
|
||||
import SingleGroup from "../groups/containers/SingleGroup";
|
||||
import AddGroup from "../groups/containers/AddGroup";
|
||||
|
||||
type Props = {
|
||||
authenticated?: boolean
|
||||
@@ -21,16 +27,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"
|
||||
@@ -53,6 +77,28 @@ class Main extends React.Component<Props> {
|
||||
path="/user/:name"
|
||||
component={SingleUser}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/groups"
|
||||
component={Groups}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
authenticated={authenticated}
|
||||
path="/group/:name"
|
||||
component={SingleGroup}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
authenticated={authenticated}
|
||||
path="/groups/add"
|
||||
component={AddGroup}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
exact
|
||||
path="/groups/:page"
|
||||
component={Groups}
|
||||
authenticated={authenticated}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,9 @@ 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 groups from "./groups/modules/groups";
|
||||
import auth from "./modules/auth";
|
||||
import pending from "./modules/pending";
|
||||
import failure from "./modules/failure";
|
||||
@@ -20,6 +23,9 @@ function createReduxStore(history: BrowserHistory) {
|
||||
pending,
|
||||
failure,
|
||||
users,
|
||||
repos,
|
||||
repositoryTypes,
|
||||
groups,
|
||||
auth
|
||||
});
|
||||
|
||||
|
||||
71
scm-ui/src/groups/components/AddMemberField.js
Normal file
71
scm-ui/src/groups/components/AddMemberField.js
Normal file
@@ -0,0 +1,71 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
import { translate } from "react-i18next";
|
||||
import { AddButton } from "../../components/buttons";
|
||||
import InputField from "../../components/forms/InputField";
|
||||
import { isMemberNameValid } from "./groupValidation";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
addMember: string => void
|
||||
};
|
||||
|
||||
type State = {
|
||||
memberToAdd: string,
|
||||
validationError: boolean
|
||||
};
|
||||
|
||||
class AddMemberField extends React.Component<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
memberToAdd: "",
|
||||
validationError: false
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div className="field">
|
||||
<InputField
|
||||
label={t("add-member-textfield.label")}
|
||||
errorMessage={t("add-member-textfield.error")}
|
||||
onChange={this.handleAddMemberChange}
|
||||
validationError={this.state.validationError}
|
||||
value={this.state.memberToAdd}
|
||||
onReturnPressed={this.appendMember}
|
||||
/>
|
||||
<AddButton
|
||||
label={t("add-member-button.label")}
|
||||
action={this.addButtonClicked}
|
||||
disabled={!isMemberNameValid(this.state.memberToAdd)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
addButtonClicked = (event: Event) => {
|
||||
event.preventDefault();
|
||||
this.appendMember();
|
||||
};
|
||||
|
||||
appendMember = () => {
|
||||
const { memberToAdd } = this.state;
|
||||
if (isMemberNameValid(memberToAdd)) {
|
||||
this.props.addMember(memberToAdd);
|
||||
this.setState({ ...this.state, memberToAdd: "" });
|
||||
}
|
||||
};
|
||||
|
||||
handleAddMemberChange = (membername: string) => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
memberToAdd: membername,
|
||||
validationError: membername.length > 0 && !isMemberNameValid(membername)
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("groups")(AddMemberField);
|
||||
151
scm-ui/src/groups/components/GroupForm.js
Normal file
151
scm-ui/src/groups/components/GroupForm.js
Normal file
@@ -0,0 +1,151 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
import InputField from "../../components/forms/InputField";
|
||||
import { SubmitButton } from "../../components/buttons";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Group } from "../types/Group";
|
||||
import * as validator from "./groupValidation";
|
||||
import AddMemberField from "./AddMemberField";
|
||||
import MemberNameTable from "./MemberNameTable";
|
||||
import Textarea from "../../components/forms/Textarea";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
submitForm: Group => void,
|
||||
loading?: boolean,
|
||||
group?: Group
|
||||
};
|
||||
|
||||
type State = {
|
||||
group: Group,
|
||||
nameValidationError: boolean
|
||||
};
|
||||
|
||||
class GroupForm extends React.Component<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
group: {
|
||||
name: "",
|
||||
description: "",
|
||||
_embedded: {
|
||||
members: []
|
||||
},
|
||||
_links: {},
|
||||
members: [],
|
||||
type: ""
|
||||
},
|
||||
nameValidationError: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { group } = this.props;
|
||||
if (group) {
|
||||
this.setState({ ...this.state, group: { ...group } });
|
||||
}
|
||||
}
|
||||
|
||||
isFalsy(value) {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isValid = () => {
|
||||
const group = this.state.group;
|
||||
return !(this.state.nameValidationError || this.isFalsy(group.name));
|
||||
};
|
||||
|
||||
submit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (this.isValid()) {
|
||||
this.props.submitForm(this.state.group);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, loading } = this.props;
|
||||
const group = this.state.group;
|
||||
let nameField = null;
|
||||
if (!this.props.group) {
|
||||
nameField = (
|
||||
<InputField
|
||||
label={t("group.name")}
|
||||
errorMessage={t("group-form.name-error")}
|
||||
onChange={this.handleGroupNameChange}
|
||||
value={group.name}
|
||||
validationError={this.state.nameValidationError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={this.submit}>
|
||||
{nameField}
|
||||
<Textarea
|
||||
label={t("group.description")}
|
||||
errorMessage={t("group-form.description-error")}
|
||||
onChange={this.handleDescriptionChange}
|
||||
value={group.description}
|
||||
validationError={false}
|
||||
/>
|
||||
<MemberNameTable
|
||||
members={this.state.group.members}
|
||||
memberListChanged={this.memberListChanged}
|
||||
/>
|
||||
<AddMemberField addMember={this.addMember} />
|
||||
<SubmitButton
|
||||
disabled={!this.isValid()}
|
||||
label={t("group-form.submit")}
|
||||
loading={loading}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
memberListChanged = membernames => {
|
||||
this.setState({
|
||||
...this.state,
|
||||
group: {
|
||||
...this.state.group,
|
||||
members: membernames
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
addMember = (membername: string) => {
|
||||
if (this.isMember(membername)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
...this.state,
|
||||
group: {
|
||||
...this.state.group,
|
||||
members: [...this.state.group.members, membername]
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
isMember = (membername: string) => {
|
||||
return this.state.group.members.includes(membername);
|
||||
};
|
||||
|
||||
handleGroupNameChange = (name: string) => {
|
||||
this.setState({
|
||||
nameValidationError: !validator.isNameValid(name),
|
||||
group: { ...this.state.group, name }
|
||||
});
|
||||
};
|
||||
|
||||
handleDescriptionChange = (description: string) => {
|
||||
this.setState({
|
||||
group: { ...this.state.group, description }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("groups")(GroupForm);
|
||||
47
scm-ui/src/groups/components/MemberNameTable.js
Normal file
47
scm-ui/src/groups/components/MemberNameTable.js
Normal file
@@ -0,0 +1,47 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import RemoveMemberButton from "./buttons/RemoveMemberButton";
|
||||
|
||||
type Props = {
|
||||
members: string[],
|
||||
t: string => string,
|
||||
memberListChanged: (string[]) => void
|
||||
};
|
||||
|
||||
type State = {};
|
||||
|
||||
class MemberNameTable extends React.Component<Props, State> {
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<label className="label">{t("group.members")}</label>
|
||||
<table className="table is-hoverable is-fullwidth">
|
||||
<tbody>
|
||||
{this.props.members.map(member => {
|
||||
return (
|
||||
<tr key={member}>
|
||||
<td key={member}>{member}</td>
|
||||
<td>
|
||||
<RemoveMemberButton
|
||||
membername={member}
|
||||
removeMember={this.removeMember}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
removeMember = (membername: string) => {
|
||||
const newMembers = this.props.members.filter(name => name !== membername);
|
||||
this.props.memberListChanged(newMembers);
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("groups")(MemberNameTable);
|
||||
19
scm-ui/src/groups/components/buttons/CreateGroupButton.js
Normal file
19
scm-ui/src/groups/components/buttons/CreateGroupButton.js
Normal file
@@ -0,0 +1,19 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { CreateButton } from "../../../components/buttons";
|
||||
|
||||
type Props = {
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class CreateGroupButton extends React.Component<Props> {
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<CreateButton label={t("create-group-button.label")} link="/groups/add" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("groups")(CreateGroupButton);
|
||||
34
scm-ui/src/groups/components/buttons/RemoveMemberButton.js
Normal file
34
scm-ui/src/groups/components/buttons/RemoveMemberButton.js
Normal file
@@ -0,0 +1,34 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { DeleteButton } from "../../../components/buttons";
|
||||
import { translate } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
membername: string,
|
||||
removeMember: string => void
|
||||
};
|
||||
|
||||
type State = {};
|
||||
|
||||
|
||||
|
||||
class RemoveMemberButton extends React.Component<Props, State> {
|
||||
render() {
|
||||
const { t , membername, removeMember} = this.props;
|
||||
return (
|
||||
<div className={classNames("is-pulled-right")}>
|
||||
<DeleteButton
|
||||
label={t("remove-member-button.label")}
|
||||
action={(event: Event) => {
|
||||
event.preventDefault();
|
||||
removeMember(membername);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("groups")(RemoveMemberButton);
|
||||
8
scm-ui/src/groups/components/groupValidation.js
Normal file
8
scm-ui/src/groups/components/groupValidation.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// @flow
|
||||
import { isNameValid } from "../../components/validation";
|
||||
|
||||
export { isNameValid };
|
||||
|
||||
export const isMemberNameValid = (name: string) => {
|
||||
return isNameValid(name);
|
||||
};
|
||||
57
scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js
Normal file
57
scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Group } from "../../types/Group";
|
||||
import { confirmAlert } from "../../../components/modals/ConfirmAlert";
|
||||
import { NavAction } from "../../../components/navigation";
|
||||
|
||||
type Props = {
|
||||
group: Group,
|
||||
confirmDialog?: boolean,
|
||||
t: string => string,
|
||||
deleteGroup: (group: Group) => void
|
||||
};
|
||||
|
||||
export class DeleteGroupNavLink extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
confirmDialog: true
|
||||
};
|
||||
|
||||
deleteGroup = () => {
|
||||
this.props.deleteGroup(this.props.group);
|
||||
};
|
||||
|
||||
confirmDelete = () => {
|
||||
const { t } = this.props;
|
||||
confirmAlert({
|
||||
title: t("delete-group-button.confirm-alert.title"),
|
||||
message: t("delete-group-button.confirm-alert.message"),
|
||||
buttons: [
|
||||
{
|
||||
label: t("delete-group-button.confirm-alert.submit"),
|
||||
onClick: () => this.deleteGroup()
|
||||
},
|
||||
{
|
||||
label: t("delete-group-button.confirm-alert.cancel"),
|
||||
onClick: () => null
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
isDeletable = () => {
|
||||
return this.props.group._links.delete;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { confirmDialog, t } = this.props;
|
||||
const action = confirmDialog ? this.confirmDelete : this.deleteGroup;
|
||||
|
||||
if (!this.isDeletable()) {
|
||||
return null;
|
||||
}
|
||||
return <NavAction label={t("delete-group-button.label")} action={action} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("groups")(DeleteGroupNavLink);
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import "../../../tests/enzyme";
|
||||
import "../../../tests/i18n";
|
||||
import DeleteGroupNavLink from "./DeleteGroupNavLink";
|
||||
|
||||
import { confirmAlert } from "../../../components/modals/ConfirmAlert";
|
||||
jest.mock("../../../components/modals/ConfirmAlert");
|
||||
|
||||
describe("DeleteGroupNavLink", () => {
|
||||
it("should render nothing, if the delete link is missing", () => {
|
||||
const group = {
|
||||
_links: {}
|
||||
};
|
||||
|
||||
const navLink = shallow(
|
||||
<DeleteGroupNavLink group={group} deleteGroup={() => {}} />
|
||||
);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should render the navLink", () => {
|
||||
const group = {
|
||||
_links: {
|
||||
delete: {
|
||||
href: "/groups"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = mount(
|
||||
<DeleteGroupNavLink group={group} deleteGroup={() => {}} />
|
||||
);
|
||||
expect(navLink.text()).not.toBe("");
|
||||
});
|
||||
|
||||
it("should open the confirm dialog on navLink click", () => {
|
||||
const group = {
|
||||
_links: {
|
||||
delete: {
|
||||
href: "/groups"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = mount(
|
||||
<DeleteGroupNavLink group={group} deleteGroup={() => {}} />
|
||||
);
|
||||
navLink.find("a").simulate("click");
|
||||
|
||||
expect(confirmAlert.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the delete group function with delete url", () => {
|
||||
const group = {
|
||||
_links: {
|
||||
delete: {
|
||||
href: "/groups"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let calledUrl = null;
|
||||
function capture(group) {
|
||||
calledUrl = group._links.delete.href;
|
||||
}
|
||||
|
||||
const navLink = mount(
|
||||
<DeleteGroupNavLink
|
||||
group={group}
|
||||
confirmDialog={false}
|
||||
deleteGroup={capture}
|
||||
/>
|
||||
);
|
||||
navLink.find("a").simulate("click");
|
||||
|
||||
expect(calledUrl).toBe("/groups");
|
||||
});
|
||||
});
|
||||
31
scm-ui/src/groups/components/navLinks/EditGroupNavLink.js
Normal file
31
scm-ui/src/groups/components/navLinks/EditGroupNavLink.js
Normal file
@@ -0,0 +1,31 @@
|
||||
//@flow
|
||||
import React from 'react';
|
||||
import NavLink from "../../../components/navigation/NavLink";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Group } from "../../types/Group";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
editUrl: string,
|
||||
group: Group
|
||||
}
|
||||
|
||||
type State = {
|
||||
}
|
||||
|
||||
class EditGroupNavLink extends React.Component<Props, State> {
|
||||
|
||||
render() {
|
||||
const { t, editUrl } = this.props;
|
||||
if (!this.isEditable()) {
|
||||
return null;
|
||||
}
|
||||
return <NavLink label={t("edit-group-button.label")} to={editUrl} />;
|
||||
}
|
||||
|
||||
isEditable = () => {
|
||||
return this.props.group._links.update;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("groups")(EditGroupNavLink);
|
||||
@@ -0,0 +1,29 @@
|
||||
//@flow
|
||||
|
||||
import React from "react";
|
||||
import { shallow } from "enzyme";
|
||||
import "../../../tests/enzyme";
|
||||
import "../../../tests/i18n";
|
||||
import EditGroupNavLink from "./EditGroupNavLink";
|
||||
|
||||
it("should render nothing, if the edit link is missing", () => {
|
||||
const group = {
|
||||
_links: {}
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditGroupNavLink group={group} editUrl='/group/edit'/>);
|
||||
expect(navLink.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should render the navLink", () => {
|
||||
const group = {
|
||||
_links: {
|
||||
update: {
|
||||
href: "/groups"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navLink = shallow(<EditGroupNavLink group={group} editUrl='/group/edit'/>);
|
||||
expect(navLink.text()).not.toBe("");
|
||||
});
|
||||
2
scm-ui/src/groups/components/navLinks/index.js
Normal file
2
scm-ui/src/groups/components/navLinks/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as DeleteGroupNavLink } from "./DeleteGroupNavLink";
|
||||
export { default as EditGroupNavLink } from "./EditGroupNavLink";
|
||||
69
scm-ui/src/groups/components/table/Details.js
Normal file
69
scm-ui/src/groups/components/table/Details.js
Normal file
@@ -0,0 +1,69 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import type { Group } from "../../types/Group";
|
||||
import { translate } from "react-i18next";
|
||||
import GroupMember from "./GroupMember";
|
||||
import DateFromNow from "../../../components/DateFromNow";
|
||||
|
||||
type Props = {
|
||||
group: Group,
|
||||
t: string => string
|
||||
};
|
||||
|
||||
class Details extends React.Component<Props> {
|
||||
render() {
|
||||
const { group, t } = this.props;
|
||||
return (
|
||||
<table className="table content">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t("group.name")}</td>
|
||||
<td>{group.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("group.description")}</td>
|
||||
<td>{group.description}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("group.type")}</td>
|
||||
<td>{group.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("group.creationDate")}</td>
|
||||
<td>
|
||||
<DateFromNow date={group.creationDate} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("group.lastModified")}</td>
|
||||
<td>
|
||||
<DateFromNow date={group.lastModified} />
|
||||
</td>
|
||||
</tr>
|
||||
{this.renderMembers()}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
renderMembers() {
|
||||
if (this.props.group.members.length > 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
{this.props.t("group.members")}
|
||||
<ul>
|
||||
{this.props.group._embedded.members.map((member, index) => {
|
||||
return <GroupMember key={index} member={member} />;
|
||||
})}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("groups")(Details);
|
||||
28
scm-ui/src/groups/components/table/GroupMember.js
Normal file
28
scm-ui/src/groups/components/table/GroupMember.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Member } from "../../types/Group";
|
||||
|
||||
type Props = {
|
||||
member: Member
|
||||
};
|
||||
|
||||
export default class GroupMember extends React.Component<Props> {
|
||||
renderLink(to: string, label: string) {
|
||||
return <Link to={to}>{label}</Link>;
|
||||
}
|
||||
|
||||
showName(to: any, member: Member) {
|
||||
if (member._links.self) {
|
||||
return this.renderLink(to, member.name);
|
||||
} else {
|
||||
return member.name;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { member } = this.props;
|
||||
const to = `/user/${member.name}`;
|
||||
return <li>{this.showName(to, member)}</li>;
|
||||
}
|
||||
}
|
||||
25
scm-ui/src/groups/components/table/GroupRow.js
Normal file
25
scm-ui/src/groups/components/table/GroupRow.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Group } from "../../types/Group";
|
||||
|
||||
type Props = {
|
||||
group: Group
|
||||
};
|
||||
|
||||
export default class GroupRow extends React.Component<Props> {
|
||||
renderLink(to: string, label: string) {
|
||||
return <Link to={to}>{label}</Link>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { group } = this.props;
|
||||
const to = `/group/${group.name}`;
|
||||
return (
|
||||
<tr>
|
||||
<td>{this.renderLink(to, group.name)}</td>
|
||||
<td className="is-hidden-mobile">{group.description}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
33
scm-ui/src/groups/components/table/GroupTable.js
Normal file
33
scm-ui/src/groups/components/table/GroupTable.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import GroupRow from "./GroupRow";
|
||||
import type { Group } from "../../types/Group";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
groups: Group[]
|
||||
};
|
||||
|
||||
class GroupTable extends React.Component<Props> {
|
||||
render() {
|
||||
const { groups, t } = this.props;
|
||||
return (
|
||||
<table className="table is-hoverable is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("group.name")}</th>
|
||||
<th className="is-hidden-mobile">{t("group.description")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((group, index) => {
|
||||
return <GroupRow key={index} group={group} />;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("groups")(GroupTable);
|
||||
3
scm-ui/src/groups/components/table/index.js
Normal file
3
scm-ui/src/groups/components/table/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Details } from "./Details";
|
||||
export { default as GroupRow } from "./GroupRow";
|
||||
export { default as GroupTable } from "./GroupTable";
|
||||
69
scm-ui/src/groups/containers/AddGroup.js
Normal file
69
scm-ui/src/groups/containers/AddGroup.js
Normal file
@@ -0,0 +1,69 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
import Page from "../../components/layout/Page";
|
||||
import { translate } from "react-i18next";
|
||||
import GroupForm from "../components/GroupForm";
|
||||
import { connect } from "react-redux";
|
||||
import { createGroup, isCreateGroupPending, getCreateGroupFailure, createGroupReset } from "../modules/groups";
|
||||
import type { Group } from "../types/Group";
|
||||
import type { History } from "history";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
createGroup: (group: Group, callback?: () => void) => void,
|
||||
history: History,
|
||||
loading?: boolean,
|
||||
error?: Error,
|
||||
resetForm: () => void,
|
||||
};
|
||||
|
||||
type State = {};
|
||||
|
||||
class AddGroup extends React.Component<Props, State> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.resetForm();
|
||||
}
|
||||
render() {
|
||||
const { t, loading, error } = this.props;
|
||||
return (
|
||||
<Page title={t("add-group.title")} subtitle={t("add-group.subtitle")} error={error}>
|
||||
<div>
|
||||
<GroupForm submitForm={group => this.createGroup(group)} loading={loading}/>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
groupCreated = () => {
|
||||
this.props.history.push("/groups");
|
||||
};
|
||||
createGroup = (group: Group) => {
|
||||
this.props.createGroup(group, this.groupCreated);
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
createGroup: (group: Group, callback?: () => void) =>
|
||||
dispatch(createGroup(group, callback)),
|
||||
resetForm: () => {
|
||||
dispatch(createGroupReset());
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const loading = isCreateGroupPending(state);
|
||||
const error = getCreateGroupFailure(state);
|
||||
return {
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("groups")(AddGroup));
|
||||
71
scm-ui/src/groups/containers/EditGroup.js
Normal file
71
scm-ui/src/groups/containers/EditGroup.js
Normal file
@@ -0,0 +1,71 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import GroupForm from "../components/GroupForm";
|
||||
import { modifyGroup, fetchGroup } from "../modules/groups";
|
||||
import type { History } from "history";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { Group } from "../types/Group";
|
||||
import { isModifyGroupPending, getModifyGroupFailure } from "../modules/groups";
|
||||
import ErrorNotification from "../../components/ErrorNotification";
|
||||
|
||||
type Props = {
|
||||
group: Group,
|
||||
modifyGroup: (group: Group, callback?: () => void) => void,
|
||||
fetchGroup: (name: string) => void,
|
||||
history: History,
|
||||
loading?: boolean,
|
||||
error: Error
|
||||
};
|
||||
|
||||
class EditGroup extends React.Component<Props> {
|
||||
groupModified = (group: Group) => () => {
|
||||
this.props.fetchGroup(group.name);
|
||||
this.props.history.push(`/group/${group.name}`);
|
||||
};
|
||||
|
||||
modifyGroup = (group: Group) => {
|
||||
this.props.modifyGroup(group, this.groupModified(group));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { group, loading, error } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<ErrorNotification error={error} />
|
||||
<GroupForm
|
||||
group={group}
|
||||
submitForm={group => {
|
||||
this.modifyGroup(group);
|
||||
}}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const loading = isModifyGroupPending(state, ownProps.group.name);
|
||||
const error = getModifyGroupFailure(state, ownProps.group.name);
|
||||
return {
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
modifyGroup: (group: Group, callback?: () => void) => {
|
||||
dispatch(modifyGroup(group, callback));
|
||||
},
|
||||
fetchGroup: (name: string) => {
|
||||
dispatch(fetchGroup(name));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(withRouter(EditGroup));
|
||||
139
scm-ui/src/groups/containers/Groups.js
Normal file
139
scm-ui/src/groups/containers/Groups.js
Normal file
@@ -0,0 +1,139 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { translate } from "react-i18next";
|
||||
import type { Group } from "../types/Group.js";
|
||||
import type { PagedCollection } from "../../types/Collection";
|
||||
import type { History } from "history";
|
||||
import { Page } from "../../components/layout";
|
||||
import { GroupTable } from "./../components/table";
|
||||
import Paginator from "../../components/Paginator";
|
||||
import CreateGroupButton from "../components/buttons/CreateGroupButton";
|
||||
|
||||
import {
|
||||
fetchGroupsByPage,
|
||||
fetchGroupsByLink,
|
||||
getGroupsFromState,
|
||||
isFetchGroupsPending,
|
||||
getFetchGroupsFailure,
|
||||
isPermittedToCreateGroups,
|
||||
selectListAsCollection
|
||||
} from "../modules/groups";
|
||||
|
||||
type Props = {
|
||||
groups: Group[],
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
canAddGroups: boolean,
|
||||
list: PagedCollection,
|
||||
page: number,
|
||||
|
||||
// context objects
|
||||
t: string => string,
|
||||
history: History,
|
||||
|
||||
// dispatch functions
|
||||
fetchGroupsByPage: (page: number) => void,
|
||||
fetchGroupsByLink: (link: string) => void
|
||||
};
|
||||
|
||||
class Groups extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.fetchGroupsByPage(this.props.page);
|
||||
}
|
||||
|
||||
onPageChange = (link: string) => {
|
||||
this.props.fetchGroupsByLink(link);
|
||||
};
|
||||
|
||||
/**
|
||||
* reflect page transitions in the uri
|
||||
*/
|
||||
componentDidUpdate = (prevProps: Props) => {
|
||||
const { page, list } = this.props;
|
||||
if (list.page >= 0) {
|
||||
// backend starts paging by 0
|
||||
const statePage: number = list.page + 1;
|
||||
if (page !== statePage) {
|
||||
this.props.history.push(`/groups/${statePage}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { groups, loading, error, t } = this.props;
|
||||
return (
|
||||
<Page
|
||||
title={t("groups.title")}
|
||||
subtitle={t("groups.subtitle")}
|
||||
loading={loading || !groups}
|
||||
error={error}
|
||||
>
|
||||
<GroupTable groups={groups} />
|
||||
{this.renderPaginator()}
|
||||
{this.renderCreateButton()}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
renderPaginator() {
|
||||
const { list } = this.props;
|
||||
if (list) {
|
||||
return <Paginator collection={list} onPageChange={this.onPageChange} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
renderCreateButton() {
|
||||
if (this.props.canAddGroups) {
|
||||
return <CreateGroupButton />;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 groups = getGroupsFromState(state);
|
||||
const loading = isFetchGroupsPending(state);
|
||||
const error = getFetchGroupsFailure(state);
|
||||
|
||||
const page = getPageFromProps(ownProps);
|
||||
const canAddGroups = isPermittedToCreateGroups(state);
|
||||
const list = selectListAsCollection(state);
|
||||
|
||||
return {
|
||||
groups,
|
||||
loading,
|
||||
error,
|
||||
canAddGroups,
|
||||
list,
|
||||
page
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchGroupsByPage: (page: number) => {
|
||||
dispatch(fetchGroupsByPage(page));
|
||||
},
|
||||
fetchGroupsByLink: (link: string) => {
|
||||
dispatch(fetchGroupsByLink(link));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("groups")(Groups));
|
||||
143
scm-ui/src/groups/containers/SingleGroup.js
Normal file
143
scm-ui/src/groups/containers/SingleGroup.js
Normal file
@@ -0,0 +1,143 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Page } from "../../components/layout";
|
||||
import { Route } from "react-router";
|
||||
import { Details } from "./../components/table";
|
||||
import { DeleteGroupNavLink, EditGroupNavLink } from "./../components/navLinks";
|
||||
import type { Group } from "../types/Group";
|
||||
import type { History } from "history";
|
||||
import {
|
||||
deleteGroup,
|
||||
fetchGroup,
|
||||
getGroupByName,
|
||||
isFetchGroupPending,
|
||||
getFetchGroupFailure,
|
||||
getDeleteGroupFailure,
|
||||
isDeleteGroupPending,
|
||||
} from "../modules/groups";
|
||||
import Loading from "../../components/Loading";
|
||||
|
||||
import { Navigation, Section, NavLink } from "../../components/navigation";
|
||||
import ErrorPage from "../../components/ErrorPage";
|
||||
import { translate } from "react-i18next";
|
||||
import EditGroup from "./EditGroup";
|
||||
|
||||
type Props = {
|
||||
name: string,
|
||||
group: Group,
|
||||
loading: boolean,
|
||||
error: Error,
|
||||
|
||||
// dispatcher functions
|
||||
deleteGroup: (group: Group, callback?: () => void) => void,
|
||||
fetchGroup: string => void,
|
||||
|
||||
// context objects
|
||||
t: string => string,
|
||||
match: any,
|
||||
history: History
|
||||
};
|
||||
|
||||
class SingleGroup extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
this.props.fetchGroup(this.props.name);
|
||||
}
|
||||
|
||||
stripEndingSlash = (url: string) => {
|
||||
if (url.endsWith("/")) {
|
||||
return url.substring(0, url.length - 2);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
deleteGroup = (group: Group) => {
|
||||
this.props.deleteGroup(group, this.groupDeleted);
|
||||
};
|
||||
|
||||
groupDeleted = () => {
|
||||
this.props.history.push("/groups");
|
||||
};
|
||||
|
||||
matchedUrl = () => {
|
||||
return this.stripEndingSlash(this.props.match.url);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, loading, error, group } = this.props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t("single-group.error-title")}
|
||||
subtitle={t("single-group.error-subtitle")}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!group || loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const url = this.matchedUrl();
|
||||
|
||||
return (
|
||||
<Page title={group.name}>
|
||||
<div className="columns">
|
||||
<div className="column is-three-quarters">
|
||||
<Route path={url} exact component={() => <Details group={group} />} />
|
||||
<Route path={`${url}/edit`} exact component={() => <EditGroup group={group} />} />
|
||||
</div>
|
||||
<div className="column">
|
||||
<Navigation>
|
||||
<Section label={t("single-group.navigation-label")}>
|
||||
<NavLink
|
||||
to={`${url}`}
|
||||
label={t("single-group.information-label")}
|
||||
/>
|
||||
</Section>
|
||||
<Section label={t("single-group.actions-label")}>
|
||||
<DeleteGroupNavLink group={group} deleteGroup={this.deleteGroup} />
|
||||
<EditGroupNavLink group={group} editUrl={`${url}/edit`}/>
|
||||
<NavLink to="/groups" label={t("single-group.back-label")} />
|
||||
</Section>
|
||||
</Navigation>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const name = ownProps.match.params.name;
|
||||
const group = getGroupByName(state, name);
|
||||
const loading =
|
||||
isFetchGroupPending(state, name) || isDeleteGroupPending(state, name);
|
||||
const error =
|
||||
getFetchGroupFailure(state, name) || getDeleteGroupFailure(state, name);
|
||||
|
||||
return {
|
||||
name,
|
||||
group,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchGroup: (name: string) => {
|
||||
dispatch(fetchGroup(name));
|
||||
},
|
||||
deleteGroup: (group: Group, callback?: () => void) => {
|
||||
dispatch(deleteGroup(group, callback));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(translate("groups")(SingleGroup));
|
||||
473
scm-ui/src/groups/modules/groups.js
Normal file
473
scm-ui/src/groups/modules/groups.js
Normal file
@@ -0,0 +1,473 @@
|
||||
// @flow
|
||||
import { apiClient } from "../../apiclient";
|
||||
import { isPending } from "../../modules/pending";
|
||||
import { getFailure } from "../../modules/failure";
|
||||
import * as types from "../../modules/types";
|
||||
import { combineReducers, Dispatch } from "redux";
|
||||
import type { Action } from "../../types/Action";
|
||||
import type { PagedCollection } from "../../types/Collection";
|
||||
import type { Group } from "../types/Group";
|
||||
|
||||
export const FETCH_GROUPS = "scm/groups/FETCH_GROUPS";
|
||||
export const FETCH_GROUPS_PENDING = `${FETCH_GROUPS}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_GROUPS_SUCCESS = `${FETCH_GROUPS}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_GROUPS_FAILURE = `${FETCH_GROUPS}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const FETCH_GROUP = "scm/groups/FETCH_GROUP";
|
||||
export const FETCH_GROUP_PENDING = `${FETCH_GROUP}_${types.PENDING_SUFFIX}`;
|
||||
export const FETCH_GROUP_SUCCESS = `${FETCH_GROUP}_${types.SUCCESS_SUFFIX}`;
|
||||
export const FETCH_GROUP_FAILURE = `${FETCH_GROUP}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const CREATE_GROUP = "scm/groups/CREATE_GROUP";
|
||||
export const CREATE_GROUP_PENDING = `${CREATE_GROUP}_${types.PENDING_SUFFIX}`;
|
||||
export const CREATE_GROUP_SUCCESS = `${CREATE_GROUP}_${types.SUCCESS_SUFFIX}`;
|
||||
export const CREATE_GROUP_FAILURE = `${CREATE_GROUP}_${types.FAILURE_SUFFIX}`;
|
||||
export const CREATE_GROUP_RESET = `${CREATE_GROUP}_${types.RESET_SUFFIX}`;
|
||||
|
||||
export const MODIFY_GROUP = "scm/groups/MODIFY_GROUP";
|
||||
export const MODIFY_GROUP_PENDING = `${MODIFY_GROUP}_${types.PENDING_SUFFIX}`;
|
||||
export const MODIFY_GROUP_SUCCESS = `${MODIFY_GROUP}_${types.SUCCESS_SUFFIX}`;
|
||||
export const MODIFY_GROUP_FAILURE = `${MODIFY_GROUP}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
export const DELETE_GROUP = "scm/groups/DELETE";
|
||||
export const DELETE_GROUP_PENDING = `${DELETE_GROUP}_${types.PENDING_SUFFIX}`;
|
||||
export const DELETE_GROUP_SUCCESS = `${DELETE_GROUP}_${types.SUCCESS_SUFFIX}`;
|
||||
export const DELETE_GROUP_FAILURE = `${DELETE_GROUP}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
const GROUPS_URL = "groups";
|
||||
const CONTENT_TYPE_GROUP = "application/vnd.scmm-group+json;v=2";
|
||||
|
||||
// fetch groups
|
||||
export function fetchGroups() {
|
||||
return fetchGroupsByLink(GROUPS_URL);
|
||||
}
|
||||
|
||||
export function fetchGroupsByPage(page: number) {
|
||||
// backend start counting by 0
|
||||
return fetchGroupsByLink(GROUPS_URL + "?page=" + (page - 1));
|
||||
}
|
||||
|
||||
export function fetchGroupsByLink(link: string) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchGroupsPending());
|
||||
return apiClient
|
||||
.get(link)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
dispatch(fetchGroupsSuccess(data));
|
||||
})
|
||||
.catch(cause => {
|
||||
const error = new Error(`could not fetch groups: ${cause.message}`);
|
||||
dispatch(fetchGroupsFailure(GROUPS_URL, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupsPending(): Action {
|
||||
return {
|
||||
type: FETCH_GROUPS_PENDING
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupsSuccess(groups: any): Action {
|
||||
return {
|
||||
type: FETCH_GROUPS_SUCCESS,
|
||||
payload: groups
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupsFailure(url: string, error: Error): Action {
|
||||
return {
|
||||
type: FETCH_GROUPS_FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
url
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//fetch group
|
||||
export function fetchGroup(name: string) {
|
||||
const groupUrl = GROUPS_URL + "/" + name;
|
||||
return function(dispatch: any) {
|
||||
dispatch(fetchGroupPending(name));
|
||||
return apiClient
|
||||
.get(groupUrl)
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
dispatch(fetchGroupSuccess(data));
|
||||
})
|
||||
.catch(cause => {
|
||||
const error = new Error(`could not fetch group: ${cause.message}`);
|
||||
dispatch(fetchGroupFailure(name, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupPending(name: string): Action {
|
||||
return {
|
||||
type: FETCH_GROUP_PENDING,
|
||||
payload: name,
|
||||
itemId: name
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupSuccess(group: any): Action {
|
||||
return {
|
||||
type: FETCH_GROUP_SUCCESS,
|
||||
payload: group,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchGroupFailure(name: string, error: Error): Action {
|
||||
return {
|
||||
type: FETCH_GROUP_FAILURE,
|
||||
payload: {
|
||||
name,
|
||||
error
|
||||
},
|
||||
itemId: name
|
||||
};
|
||||
}
|
||||
|
||||
//create group
|
||||
export function createGroup(group: Group, callback?: () => void) {
|
||||
return function(dispatch: Dispatch) {
|
||||
dispatch(createGroupPending());
|
||||
return apiClient
|
||||
.post(GROUPS_URL, group, CONTENT_TYPE_GROUP)
|
||||
.then(() => {
|
||||
dispatch(createGroupSuccess());
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(
|
||||
createGroupFailure(
|
||||
new Error(`Failed to create group ${group.name}: ${error.message}`)
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createGroupPending() {
|
||||
return {
|
||||
type: CREATE_GROUP_PENDING
|
||||
};
|
||||
}
|
||||
|
||||
export function createGroupSuccess() {
|
||||
return {
|
||||
type: CREATE_GROUP_SUCCESS
|
||||
};
|
||||
}
|
||||
|
||||
export function createGroupFailure(error: Error) {
|
||||
return {
|
||||
type: CREATE_GROUP_FAILURE,
|
||||
payload: error
|
||||
};
|
||||
}
|
||||
|
||||
export function createGroupReset() {
|
||||
return {
|
||||
type: CREATE_GROUP_RESET
|
||||
};
|
||||
}
|
||||
|
||||
// modify group
|
||||
export function modifyGroup(group: Group, callback?: () => void) {
|
||||
return function(dispatch: Dispatch) {
|
||||
dispatch(modifyGroupPending(group));
|
||||
return apiClient
|
||||
.put(group._links.update.href, group, CONTENT_TYPE_GROUP)
|
||||
.then(() => {
|
||||
dispatch(modifyGroupSuccess(group));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(cause => {
|
||||
dispatch(
|
||||
modifyGroupFailure(
|
||||
group,
|
||||
new Error(`could not modify group ${group.name}: ${cause.message}`)
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyGroupPending(group: Group): Action {
|
||||
return {
|
||||
type: MODIFY_GROUP_PENDING,
|
||||
payload: group,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyGroupSuccess(group: Group): Action {
|
||||
return {
|
||||
type: MODIFY_GROUP_SUCCESS,
|
||||
payload: group,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function modifyGroupFailure(group: Group, error: Error): Action {
|
||||
return {
|
||||
type: MODIFY_GROUP_FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
group
|
||||
},
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
//delete group
|
||||
|
||||
export function deleteGroup(group: Group, callback?: () => void) {
|
||||
return function(dispatch: any) {
|
||||
dispatch(deleteGroupPending(group));
|
||||
return apiClient
|
||||
.delete(group._links.delete.href)
|
||||
.then(() => {
|
||||
dispatch(deleteGroupSuccess(group));
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(cause => {
|
||||
const error = new Error(
|
||||
`could not delete group ${group.name}: ${cause.message}`
|
||||
);
|
||||
dispatch(deleteGroupFailure(group, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteGroupPending(group: Group): Action {
|
||||
return {
|
||||
type: DELETE_GROUP_PENDING,
|
||||
payload: group,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteGroupSuccess(group: Group): Action {
|
||||
return {
|
||||
type: DELETE_GROUP_SUCCESS,
|
||||
payload: group,
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteGroupFailure(group: Group, error: Error): Action {
|
||||
return {
|
||||
type: DELETE_GROUP_FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
group
|
||||
},
|
||||
itemId: group.name
|
||||
};
|
||||
}
|
||||
|
||||
//reducer
|
||||
function extractGroupsByNames(
|
||||
groups: Group[],
|
||||
groupNames: string[],
|
||||
oldGroupsByNames: Object
|
||||
) {
|
||||
const groupsByNames = {};
|
||||
|
||||
for (let group of groups) {
|
||||
groupsByNames[group.name] = group;
|
||||
}
|
||||
|
||||
for (let groupName in oldGroupsByNames) {
|
||||
groupsByNames[groupName] = oldGroupsByNames[groupName];
|
||||
}
|
||||
return groupsByNames;
|
||||
}
|
||||
|
||||
function deleteGroupInGroupsByNames(groups: {}, groupName: string) {
|
||||
let newGroups = {};
|
||||
for (let groupname in groups) {
|
||||
if (groupname !== groupName) newGroups[groupname] = groups[groupname];
|
||||
}
|
||||
return newGroups;
|
||||
}
|
||||
|
||||
function deleteGroupInEntries(groups: [], groupName: string) {
|
||||
let newGroups = [];
|
||||
for (let group of groups) {
|
||||
if (group !== groupName) newGroups.push(group);
|
||||
}
|
||||
return newGroups;
|
||||
}
|
||||
|
||||
const reducerByName = (state: any, groupname: string, newGroupState: any) => {
|
||||
const newGroupsByNames = {
|
||||
...state,
|
||||
[groupname]: newGroupState
|
||||
};
|
||||
|
||||
return newGroupsByNames;
|
||||
};
|
||||
|
||||
function listReducer(state: any = {}, action: any = {}) {
|
||||
switch (action.type) {
|
||||
case FETCH_GROUPS_SUCCESS:
|
||||
const groups = action.payload._embedded.groups;
|
||||
const groupNames = groups.map(group => group.name);
|
||||
return {
|
||||
...state,
|
||||
entries: groupNames,
|
||||
entry: {
|
||||
groupCreatePermission: action.payload._links.create ? true : false,
|
||||
page: action.payload.page,
|
||||
pageTotal: action.payload.pageTotal,
|
||||
_links: action.payload._links
|
||||
}
|
||||
};
|
||||
// Delete single group actions
|
||||
case DELETE_GROUP_SUCCESS:
|
||||
const newGroupEntries = deleteGroupInEntries(
|
||||
state.entries,
|
||||
action.payload.name
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
entries: newGroupEntries
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function byNamesReducer(state: any = {}, action: any = {}) {
|
||||
switch (action.type) {
|
||||
// Fetch all groups actions
|
||||
case FETCH_GROUPS_SUCCESS:
|
||||
const groups = action.payload._embedded.groups;
|
||||
const groupNames = groups.map(group => group.name);
|
||||
const byNames = extractGroupsByNames(groups, groupNames, state.byNames);
|
||||
return {
|
||||
...byNames
|
||||
};
|
||||
case FETCH_GROUP_SUCCESS:
|
||||
return reducerByName(state, action.payload.name, action.payload);
|
||||
case MODIFY_GROUP_SUCCESS:
|
||||
return reducerByName(state, action.payload.name, action.payload);
|
||||
case DELETE_GROUP_SUCCESS:
|
||||
const newGroupByNames = deleteGroupInGroupsByNames(
|
||||
state,
|
||||
action.payload.name
|
||||
);
|
||||
return newGroupByNames;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
list: listReducer,
|
||||
byNames: byNamesReducer
|
||||
});
|
||||
|
||||
// selectors
|
||||
|
||||
const selectList = (state: Object) => {
|
||||
if (state.groups && state.groups.list) {
|
||||
return state.groups.list;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const selectListEntry = (state: Object): Object => {
|
||||
const list = selectList(state);
|
||||
if (list.entry) {
|
||||
return list.entry;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const selectListAsCollection = (state: Object): PagedCollection => {
|
||||
return selectListEntry(state);
|
||||
};
|
||||
|
||||
export const isPermittedToCreateGroups = (state: Object): boolean => {
|
||||
const permission = selectListEntry(state).groupCreatePermission;
|
||||
if (permission) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export function getGroupsFromState(state: Object) {
|
||||
const groupNames = selectList(state).entries;
|
||||
if (!groupNames) {
|
||||
return null;
|
||||
}
|
||||
const groupEntries: Group[] = [];
|
||||
|
||||
for (let groupName of groupNames) {
|
||||
groupEntries.push(state.groups.byNames[groupName]);
|
||||
}
|
||||
|
||||
return groupEntries;
|
||||
}
|
||||
|
||||
export function isFetchGroupsPending(state: Object) {
|
||||
return isPending(state, FETCH_GROUPS);
|
||||
}
|
||||
|
||||
export function getFetchGroupsFailure(state: Object) {
|
||||
return getFailure(state, FETCH_GROUPS);
|
||||
}
|
||||
|
||||
export function isCreateGroupPending(state: Object) {
|
||||
return isPending(state, CREATE_GROUP);
|
||||
}
|
||||
|
||||
export function getCreateGroupFailure(state: Object) {
|
||||
return getFailure(state, CREATE_GROUP);
|
||||
}
|
||||
|
||||
export function isModifyGroupPending(state: Object, name: string) {
|
||||
return isPending(state, MODIFY_GROUP, name);
|
||||
}
|
||||
|
||||
export function getModifyGroupFailure(state: Object, name: string) {
|
||||
return getFailure(state, MODIFY_GROUP, name);
|
||||
}
|
||||
|
||||
export function getGroupByName(state: Object, name: string) {
|
||||
if (state.groups && state.groups.byNames) {
|
||||
return state.groups.byNames[name];
|
||||
}
|
||||
}
|
||||
|
||||
export function isFetchGroupPending(state: Object, name: string) {
|
||||
return isPending(state, FETCH_GROUP, name);
|
||||
}
|
||||
|
||||
export function getFetchGroupFailure(state: Object, name: string) {
|
||||
return getFailure(state, FETCH_GROUP, name);
|
||||
}
|
||||
|
||||
export function isDeleteGroupPending(state: Object, name: string) {
|
||||
return isPending(state, DELETE_GROUP, name);
|
||||
}
|
||||
|
||||
export function getDeleteGroupFailure(state: Object, name: string) {
|
||||
return getFailure(state, DELETE_GROUP, name);
|
||||
}
|
||||
632
scm-ui/src/groups/modules/groups.test.js
Normal file
632
scm-ui/src/groups/modules/groups.test.js
Normal file
@@ -0,0 +1,632 @@
|
||||
//@flow
|
||||
import configureMockStore from "redux-mock-store";
|
||||
import thunk from "redux-thunk";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import reducer, {
|
||||
fetchGroups,
|
||||
FETCH_GROUPS,
|
||||
FETCH_GROUPS_PENDING,
|
||||
FETCH_GROUPS_SUCCESS,
|
||||
FETCH_GROUPS_FAILURE,
|
||||
fetchGroupsSuccess,
|
||||
isPermittedToCreateGroups,
|
||||
getGroupsFromState,
|
||||
getFetchGroupsFailure,
|
||||
isFetchGroupsPending,
|
||||
selectListAsCollection,
|
||||
fetchGroup,
|
||||
FETCH_GROUP_PENDING,
|
||||
FETCH_GROUP_SUCCESS,
|
||||
FETCH_GROUP_FAILURE,
|
||||
fetchGroupSuccess,
|
||||
getFetchGroupFailure,
|
||||
FETCH_GROUP,
|
||||
isFetchGroupPending,
|
||||
getGroupByName,
|
||||
createGroup,
|
||||
CREATE_GROUP_SUCCESS,
|
||||
CREATE_GROUP_PENDING,
|
||||
CREATE_GROUP_FAILURE,
|
||||
isCreateGroupPending,
|
||||
CREATE_GROUP,
|
||||
getCreateGroupFailure,
|
||||
deleteGroup,
|
||||
DELETE_GROUP_PENDING,
|
||||
DELETE_GROUP_SUCCESS,
|
||||
DELETE_GROUP_FAILURE,
|
||||
DELETE_GROUP,
|
||||
deleteGroupSuccess,
|
||||
isDeleteGroupPending,
|
||||
getDeleteGroupFailure,
|
||||
modifyGroup,
|
||||
MODIFY_GROUP_PENDING,
|
||||
MODIFY_GROUP_SUCCESS,
|
||||
MODIFY_GROUP_FAILURE
|
||||
} from "./groups";
|
||||
const GROUPS_URL = "/scm/api/rest/v2/groups";
|
||||
|
||||
const error = new Error("You have an error!");
|
||||
|
||||
const humanGroup = {
|
||||
creationDate: "2018-07-31T08:39:07.860Z",
|
||||
description: "This is a group",
|
||||
name: "humanGroup",
|
||||
type: "xml",
|
||||
properties: {},
|
||||
members: ["userZaphod"],
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/groups/humanGroup"
|
||||
},
|
||||
delete: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/groups/humanGroup"
|
||||
},
|
||||
update: {
|
||||
href:"http://localhost:8081/scm/api/rest/v2/groups/humanGroup"
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
members: [
|
||||
{
|
||||
name: "userZaphod",
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/users/userZaphod"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const emptyGroup = {
|
||||
creationDate: "2018-07-31T08:39:07.860Z",
|
||||
description: "This is a group",
|
||||
name: "emptyGroup",
|
||||
type: "xml",
|
||||
properties: {},
|
||||
members: [],
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/groups/emptyGroup"
|
||||
},
|
||||
delete: {
|
||||
href: "http://localhost:8081/scm/api/rest/v2/groups/emptyGroup"
|
||||
},
|
||||
update: {
|
||||
href:"http://localhost:8081/scm/api/rest/v2/groups/emptyGroup"
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
members: []
|
||||
}
|
||||
};
|
||||
|
||||
const responseBody = {
|
||||
page: 0,
|
||||
pageTotal: 1,
|
||||
_links: {
|
||||
self: {
|
||||
href: "http://localhost:3000/scm/api/rest/v2/groups/?page=0&pageSize=10"
|
||||
},
|
||||
first: {
|
||||
href: "http://localhost:3000/scm/api/rest/v2/groups/?page=0&pageSize=10"
|
||||
},
|
||||
last: {
|
||||
href: "http://localhost:3000/scm/api/rest/v2/groups/?page=0&pageSize=10"
|
||||
},
|
||||
create: {
|
||||
href: "http://localhost:3000/scm/api/rest/v2/groups/"
|
||||
}
|
||||
},
|
||||
_embedded: {
|
||||
groups: [humanGroup, emptyGroup]
|
||||
}
|
||||
};
|
||||
|
||||
const response = {
|
||||
headers: { "content-type": "application/json" },
|
||||
responseBody
|
||||
};
|
||||
|
||||
describe("groups fetch()", () => {
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it("should successfully fetch groups", () => {
|
||||
fetchMock.getOnce(GROUPS_URL, response);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_GROUPS_PENDING },
|
||||
{
|
||||
type: FETCH_GROUPS_SUCCESS,
|
||||
payload: response
|
||||
}
|
||||
];
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(fetchGroups()).then(() => {
|
||||
expect(store.getActions()).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail getting groups on HTTP 500", () => {
|
||||
fetchMock.getOnce(GROUPS_URL, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchGroups()).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_GROUPS_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_GROUPS_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should sucessfully fetch single group", () => {
|
||||
fetchMock.getOnce(GROUPS_URL + "/humanGroup", humanGroup);
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchGroup("humanGroup")).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail fetching single group on HTTP 500", () => {
|
||||
fetchMock.getOnce(GROUPS_URL + "/humanGroup", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(fetchGroup("humanGroup")).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("should successfully create group", () => {
|
||||
fetchMock.postOnce(GROUPS_URL, {
|
||||
status: 201
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createGroup(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call the callback after creating group", () => {
|
||||
fetchMock.postOnce(GROUPS_URL, {
|
||||
status: 201
|
||||
});
|
||||
let called = false;
|
||||
|
||||
const callMe = () => {
|
||||
called = true;
|
||||
}
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createGroup(humanGroup, callMe)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
|
||||
expect(called).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("should fail creating group on HTTP 500", () => {
|
||||
fetchMock.postOnce(GROUPS_URL, {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(createGroup(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(CREATE_GROUP_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
expect(actions[1].payload instanceof Error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should successfully modify group", () => {
|
||||
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
|
||||
status: 204
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(modifyGroup(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS);
|
||||
expect(actions[1].payload).toEqual(humanGroup)
|
||||
});
|
||||
})
|
||||
|
||||
it("should call the callback after modifying group", () => {
|
||||
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
|
||||
status: 204
|
||||
});
|
||||
|
||||
let called = false;
|
||||
const callback = () => {
|
||||
called = true;
|
||||
}
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(modifyGroup(humanGroup, callback)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS);
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
})
|
||||
|
||||
it("should fail modifying group on HTTP 500", () => {
|
||||
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
|
||||
return store.dispatch(modifyGroup(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
|
||||
expect(actions[1].type).toEqual(MODIFY_GROUP_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
})
|
||||
|
||||
it("should delete successfully group humanGroup", () => {
|
||||
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
|
||||
status: 204
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteGroup(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions.length).toBe(2);
|
||||
expect(actions[0].type).toEqual(DELETE_GROUP_PENDING);
|
||||
expect(actions[0].payload).toBe(humanGroup);
|
||||
expect(actions[1].type).toEqual(DELETE_GROUP_SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call the callback, after successful delete", () => {
|
||||
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
|
||||
status: 204
|
||||
});
|
||||
|
||||
let called = false;
|
||||
const callMe = () => {
|
||||
called = true;
|
||||
};
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteGroup(humanGroup, callMe)).then(() => {
|
||||
expect(called).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to delete group humanGroup", () => {
|
||||
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
|
||||
status: 500
|
||||
});
|
||||
|
||||
const store = mockStore({});
|
||||
return store.dispatch(deleteGroup(humanGroup)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions[0].type).toEqual(DELETE_GROUP_PENDING);
|
||||
expect(actions[0].payload).toBe(humanGroup);
|
||||
expect(actions[1].type).toEqual(DELETE_GROUP_FAILURE);
|
||||
expect(actions[1].payload).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("groups reducer", () => {
|
||||
|
||||
it("should update state correctly according to FETCH_GROUPS_SUCCESS action", () => {
|
||||
|
||||
const newState = reducer({}, fetchGroupsSuccess(responseBody));
|
||||
|
||||
expect(newState.list).toEqual({
|
||||
entries: ["humanGroup", "emptyGroup"],
|
||||
entry: {
|
||||
groupCreatePermission: true,
|
||||
page: 0,
|
||||
pageTotal: 1,
|
||||
_links: responseBody._links
|
||||
}
|
||||
});
|
||||
|
||||
expect(newState.byNames).toEqual({
|
||||
humanGroup: humanGroup,
|
||||
emptyGroup: emptyGroup
|
||||
});
|
||||
|
||||
expect(newState.list.entry.groupCreatePermission).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should set groupCreatePermission to true if update link is present", () => {
|
||||
const newState = reducer({}, fetchGroupsSuccess(responseBody));
|
||||
|
||||
expect(newState.list.entry.groupCreatePermission).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not replace whole byNames map when fetching groups", () => {
|
||||
const oldState = {
|
||||
byNames: {
|
||||
emptyGroup: emptyGroup
|
||||
}
|
||||
};
|
||||
|
||||
const newState = reducer(oldState, fetchGroupsSuccess(responseBody));
|
||||
expect(newState.byNames["humanGroup"]).toBeDefined();
|
||||
expect(newState.byNames["emptyGroup"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should set groupCreatePermission to true if create link is present", () => {
|
||||
const newState = reducer({}, fetchGroupsSuccess(responseBody));
|
||||
|
||||
expect(newState.list.entry.groupCreatePermission).toBeTruthy();
|
||||
expect(newState.list.entries).toEqual(["humanGroup", "emptyGroup"]);
|
||||
expect(newState.byNames["emptyGroup"]).toBeTruthy();
|
||||
expect(newState.byNames["humanGroup"]).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
it("should update state according to FETCH_GROUP_SUCCESS action", () => {
|
||||
const newState = reducer({}, fetchGroupSuccess(emptyGroup));
|
||||
expect(newState.byNames["emptyGroup"]).toBe(emptyGroup);
|
||||
});
|
||||
|
||||
it("should affect groups state nor the state of other groups", () => {
|
||||
const newState = reducer(
|
||||
{
|
||||
list: {
|
||||
entries: ["humanGroup"]
|
||||
}
|
||||
},
|
||||
fetchGroupSuccess(emptyGroup)
|
||||
);
|
||||
expect(newState.byNames["emptyGroup"]).toBe(emptyGroup);
|
||||
expect(newState.list.entries).toEqual(["humanGroup"]);
|
||||
});
|
||||
|
||||
it("should remove group from state when delete succeeds", () => {
|
||||
const state = {
|
||||
list: {
|
||||
entries: ["humanGroup", "emptyGroup"]
|
||||
},
|
||||
byNames: {
|
||||
humanGroup: humanGroup,
|
||||
emptyGroup: emptyGroup
|
||||
}
|
||||
};
|
||||
|
||||
const newState = reducer(state, deleteGroupSuccess(emptyGroup));
|
||||
expect(newState.byNames["humanGroup"]).toBeDefined();
|
||||
expect(newState.byNames["emptyGroup"]).toBeFalsy();
|
||||
expect(newState.list.entries).toEqual(["humanGroup"]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("selector tests", () => {
|
||||
it("should return an empty object", () => {
|
||||
expect(selectListAsCollection({})).toEqual({});
|
||||
expect(selectListAsCollection({ groups: { a: "a" } })).toEqual({});
|
||||
});
|
||||
|
||||
it("should return a state slice collection", () => {
|
||||
const collection = {
|
||||
page: 3,
|
||||
totalPages: 42
|
||||
};
|
||||
|
||||
const state = {
|
||||
groups: {
|
||||
list: {
|
||||
entry: collection
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(selectListAsCollection(state)).toBe(collection);
|
||||
});
|
||||
|
||||
it("should return false when groupCreatePermission is false", () => {
|
||||
expect(isPermittedToCreateGroups({})).toBe(false);
|
||||
expect(isPermittedToCreateGroups({ groups: { list: { entry: {} } } })).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
isPermittedToCreateGroups({
|
||||
groups: { list: { entry: { groupCreatePermission: false } } }
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when groupCreatePermission is true", () => {
|
||||
const state = {
|
||||
groups: {
|
||||
list: {
|
||||
entry: {
|
||||
groupCreatePermission: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(isPermittedToCreateGroups(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("should get groups from state", () => {
|
||||
const state = {
|
||||
groups: {
|
||||
list: {
|
||||
entries: ["a", "b"]
|
||||
},
|
||||
byNames: {
|
||||
a: { name: "a" },
|
||||
b: { name: "b" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(getGroupsFromState(state)).toEqual([{ name: "a" }, { name: "b" }]);
|
||||
});
|
||||
|
||||
it("should return null when there are no groups in the state", () => {
|
||||
expect(getGroupsFromState({})).toBe(null)
|
||||
});
|
||||
|
||||
it("should return true, when fetch groups is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_GROUPS]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchGroupsPending(state)).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch groups is not pending", () => {
|
||||
expect(isFetchGroupsPending({})).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch groups did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_GROUPS]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchGroupsFailure(state)).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch groups did not fail", () => {
|
||||
expect(getFetchGroupsFailure({})).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return group emptyGroup", () => {
|
||||
const state = {
|
||||
groups: {
|
||||
byNames: {
|
||||
emptyGroup: emptyGroup
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(getGroupByName(state, "emptyGroup")).toEqual(emptyGroup);
|
||||
});
|
||||
|
||||
it("should return true, when fetch group humanGroup is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[FETCH_GROUP + "/humanGroup"]: true
|
||||
}
|
||||
};
|
||||
expect(isFetchGroupPending(state, "humanGroup")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when fetch group humanGroup is not pending", () => {
|
||||
expect(isFetchGroupPending({}, "humanGroup")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when fetch group humanGroup did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[FETCH_GROUP + "/humanGroup"]: error
|
||||
}
|
||||
};
|
||||
expect(getFetchGroupFailure(state, "humanGroup")).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when fetch group humanGroup did not fail", () => {
|
||||
expect(getFetchGroupFailure({}, "humanGroup")).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return true if create group is pending", () => {
|
||||
expect(isCreateGroupPending({pending: {
|
||||
[CREATE_GROUP]: true
|
||||
}})).toBeTruthy();
|
||||
})
|
||||
|
||||
it("should return false if create group is not pending", () => {
|
||||
expect(isCreateGroupPending({})).toBe(false);
|
||||
})
|
||||
|
||||
it("should return error if creating group failed", () => {
|
||||
expect(getCreateGroupFailure({
|
||||
failure: {
|
||||
[CREATE_GROUP]: error
|
||||
}
|
||||
})).toEqual(error)
|
||||
})
|
||||
|
||||
it("should return undefined if creating group did not fail", () => {
|
||||
expect(getCreateGroupFailure({})).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
it("should return true, when delete group humanGroup is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[DELETE_GROUP + "/humanGroup"]: true
|
||||
}
|
||||
};
|
||||
expect(isDeleteGroupPending(state, "humanGroup")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false, when delete group humanGroup is not pending", () => {
|
||||
expect(isDeleteGroupPending({}, "humanGroup")).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return error when delete group humanGroup did fail", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[DELETE_GROUP + "/humanGroup"]: error
|
||||
}
|
||||
};
|
||||
expect(getDeleteGroupFailure(state, "humanGroup")).toEqual(error);
|
||||
});
|
||||
|
||||
it("should return undefined when delete group humanGroup did not fail", () => {
|
||||
expect(getDeleteGroupFailure({}, "humanGroup")).toBe(undefined);
|
||||
});
|
||||
|
||||
it("should return true, if createGroup is pending", () => {
|
||||
const state = {
|
||||
pending: {
|
||||
[CREATE_GROUP]: true
|
||||
}
|
||||
}
|
||||
expect(isCreateGroupPending(state)).toBe(true);
|
||||
})
|
||||
|
||||
it("should return false, if createGroup is not pending", () => {
|
||||
expect(isCreateGroupPending({})).toBe(false)
|
||||
})
|
||||
|
||||
it("should return error of createGroup failed", () => {
|
||||
const state = {
|
||||
failure: {
|
||||
[CREATE_GROUP]: error
|
||||
}
|
||||
}
|
||||
expect(getCreateGroupFailure(state)).toEqual(error)
|
||||
})
|
||||
});
|
||||
18
scm-ui/src/groups/types/Group.js
Normal file
18
scm-ui/src/groups/types/Group.js
Normal file
@@ -0,0 +1,18 @@
|
||||
//@flow
|
||||
import type { Collection } from "../../types/Collection";
|
||||
import type { Links } from "../../types/hal";
|
||||
|
||||
export type Member = {
|
||||
name: string,
|
||||
_links: Links
|
||||
};
|
||||
|
||||
export type Group = Collection & {
|
||||
name: string,
|
||||
description: string,
|
||||
type: string,
|
||||
members: string[],
|
||||
_embedded: {
|
||||
members: Member[]
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user