Merge with 2.0.0-m3

This commit is contained in:
René Pfeuffer
2018-08-08 15:57:05 +02:00
174 changed files with 7559 additions and 4653 deletions

4
Jenkinsfile vendored
View File

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

View File

@@ -73,6 +73,7 @@
<module>scm-ui</module>
<module>scm-webapp</module>
<module>scm-server</module>
<module>scm-it</module>
</modules>
<repositories>

View File

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

View File

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

View File

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

View File

@@ -82,4 +82,7 @@ public interface RepositoryHandler
* @since 1.15
*/
public String getVersionInformation();
@Override
RepositoryType getType();
}

View File

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

View File

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

View File

@@ -103,7 +103,7 @@ public class RepositoryManagerDecorator
* @return
*/
@Override
public Collection<Type> getConfiguredTypes()
public Collection<RepositoryType> getConfiguredTypes()
{
return decorated.getConfiguredTypes();
}

View File

@@ -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
View 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>

View 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>

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
"editor.formatOnSave": false,
// Enable per-language
"[javascript]": {
"editor.formatOnSave": true
"editor.formatOnSave": false
},
"flow.pathToFlow": "${workspaceRoot}/node_modules/.bin/flow"
}

View File

@@ -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",

View File

@@ -31,7 +31,8 @@
"primary-navigation": {
"repositories": "Repositories",
"users": "Users",
"logout": "Logout"
"logout": "Logout",
"groups": "Groups"
},
"paginator": {
"next": "Next",

View 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"
}
}
}

View 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"
}
}
}

View File

@@ -1,7 +0,0 @@
{
"repositories": {
"title": "Repositories",
"subtitle": "Repositories will be shown here",
"body": "Coming soon ..."
}
}

View File

@@ -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",

View File

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

View 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);

View 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;

View File

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

View File

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

View File

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

View 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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;

View File

@@ -1,2 +1,3 @@
export { default as Checkbox } from "./Checkbox";
export { default as InputField } from "./InputField";
export { default as Select } from "./Select";

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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">

View File

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

View File

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

View 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);

View 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);

View 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);

View 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);

View 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);

View File

@@ -0,0 +1,8 @@
// @flow
import { isNameValid } from "../../components/validation";
export { isNameValid };
export const isMemberNameValid = (name: string) => {
return isNameValid(name);
};

View 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);

View File

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

View 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);

View File

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

View File

@@ -0,0 +1,2 @@
export { default as DeleteGroupNavLink } from "./DeleteGroupNavLink";
export { default as EditGroupNavLink } from "./EditGroupNavLink";

View 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);

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

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

View 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);

View File

@@ -0,0 +1,3 @@
export { default as Details } from "./Details";
export { default as GroupRow } from "./GroupRow";
export { default as GroupTable } from "./GroupTable";

View 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));

View 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));

View 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));

View 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));

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

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

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

View File

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

View 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);

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

View 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);

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

View 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);

View 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);

View File

@@ -0,0 +1,2 @@
import RepositoryForm from "./RepositoryForm";
export default RepositoryForm;

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

View File

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

View 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);

View 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);

View 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);

View 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;

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

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

View File

@@ -0,0 +1,2 @@
import RepositoryList from "./RepositoryList";
export default RepositoryList;

View 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));

View 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)));

View 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)));

View 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));

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

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

View 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