Merge with 2.0.0-m3

This commit is contained in:
René Pfeuffer
2018-12-19 15:09:40 +01:00
145 changed files with 3447 additions and 938 deletions

View File

@@ -0,0 +1,9 @@
package sonia.scm;
import java.util.List;
public abstract class BadRequestException extends ExceptionWithContext {
public BadRequestException(List<ContextEntry> context, String message) {
super(context, message);
}
}

View File

@@ -40,13 +40,14 @@ import java.util.Collections;
* @author Sebastian Sdorra * @author Sebastian Sdorra
* @version 1.6 * @version 1.6
*/ */
public class NotSupportedFeatureException extends ExceptionWithContext { @SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
public class FeatureNotSupportedException extends BadRequestException {
private static final long serialVersionUID = 256498734456613496L; private static final long serialVersionUID = 256498734456613496L;
private static final String CODE = "9SR8G0kmU1"; private static final String CODE = "9SR8G0kmU1";
public NotSupportedFeatureException(String feature) public FeatureNotSupportedException(String feature)
{ {
super(Collections.emptyList(),createMessage(feature)); super(Collections.emptyList(),createMessage(feature));
} }

View File

@@ -38,7 +38,7 @@ package sonia.scm.repository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.NotSupportedFeatureException; import sonia.scm.FeatureNotSupportedException;
import sonia.scm.SCMContextProvider; import sonia.scm.SCMContextProvider;
import sonia.scm.event.ScmEventBus; import sonia.scm.event.ScmEventBus;
@@ -167,12 +167,12 @@ public abstract class AbstractRepositoryHandler<C extends RepositoryConfig>
* *
* @return * @return
* *
* @throws NotSupportedFeatureException * @throws FeatureNotSupportedException
*/ */
@Override @Override
public ImportHandler getImportHandler() public ImportHandler getImportHandler()
{ {
throw new NotSupportedFeatureException("import"); throw new FeatureNotSupportedException("import");
} }
/** /**

View File

@@ -45,5 +45,10 @@ public enum Feature
* The default branch of the repository is a combined branch of all * The default branch of the repository is a combined branch of all
* repository branches. * repository branches.
*/ */
COMBINED_DEFAULT_BRANCH COMBINED_DEFAULT_BRANCH,
/**
* The repository supports computation of incoming changes (either diff or list of changesets) of one branch
* in respect to another target branch.
*/
INCOMING_REVISION
} }

View File

@@ -36,7 +36,7 @@ package sonia.scm.repository;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
import sonia.scm.Handler; import sonia.scm.Handler;
import sonia.scm.NotSupportedFeatureException; import sonia.scm.FeatureNotSupportedException;
import sonia.scm.plugin.ExtensionPoint; import sonia.scm.plugin.ExtensionPoint;
/** /**
@@ -59,9 +59,9 @@ public interface RepositoryHandler
* @return {@link ImportHandler} for the repository type of this handler * @return {@link ImportHandler} for the repository type of this handler
* @since 1.12 * @since 1.12
* *
* @throws NotSupportedFeatureException * @throws FeatureNotSupportedException
*/ */
public ImportHandler getImportHandler() throws NotSupportedFeatureException; public ImportHandler getImportHandler() throws FeatureNotSupportedException;
/** /**
* Returns informations about the version of the RepositoryHandler. * Returns informations about the version of the RepositoryHandler.

View File

@@ -38,6 +38,8 @@ package sonia.scm.repository.api;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.FeatureNotSupportedException;
import sonia.scm.repository.Feature;
import sonia.scm.repository.spi.DiffCommand; import sonia.scm.repository.spi.DiffCommand;
import sonia.scm.repository.spi.DiffCommandRequest; import sonia.scm.repository.spi.DiffCommandRequest;
import sonia.scm.util.IOUtil; import sonia.scm.util.IOUtil;
@@ -45,6 +47,7 @@ import sonia.scm.util.IOUtil;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.util.Set;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
@@ -85,10 +88,12 @@ public final class DiffCommandBuilder
* only be called from the {@link RepositoryService}. * only be called from the {@link RepositoryService}.
* *
* @param diffCommand implementation of {@link DiffCommand} * @param diffCommand implementation of {@link DiffCommand}
* @param supportedFeatures The supported features of the provider
*/ */
DiffCommandBuilder(DiffCommand diffCommand) DiffCommandBuilder(DiffCommand diffCommand, Set<Feature> supportedFeatures)
{ {
this.diffCommand = diffCommand; this.diffCommand = diffCommand;
this.supportedFeatures = supportedFeatures;
} }
//~--- methods -------------------------------------------------------------- //~--- methods --------------------------------------------------------------
@@ -174,7 +179,8 @@ public final class DiffCommandBuilder
} }
/** /**
* Show the difference only for the given revision. * Show the difference only for the given revision or (using {@link #setAncestorChangeset(String)}) between this
* and another revision.
* *
* *
* @param revision revision for difference * @param revision revision for difference
@@ -187,6 +193,22 @@ public final class DiffCommandBuilder
return this; return this;
} }
/**
* Compute the incoming changes of the branch set with {@link #setRevision(String)} in respect to the changeset given
* here. In other words: What changes would be new to the ancestor changeset given here when the branch would
* be merged into it. Requires feature {@link sonia.scm.repository.Feature#INCOMING_REVISION}!
*
* @return {@code this}
*/
public DiffCommandBuilder setAncestorChangeset(String revision)
{
if (!supportedFeatures.contains(Feature.INCOMING_REVISION)) {
throw new FeatureNotSupportedException(Feature.INCOMING_REVISION.name());
}
request.setAncestorChangeset(revision);
return this;
}
//~--- get methods ---------------------------------------------------------- //~--- get methods ----------------------------------------------------------
@@ -215,6 +237,7 @@ public final class DiffCommandBuilder
/** implementation of the diff command */ /** implementation of the diff command */
private final DiffCommand diffCommand; private final DiffCommand diffCommand;
private Set<Feature> supportedFeatures;
/** request for the diff command implementation */ /** request for the diff command implementation */
private final DiffCommandRequest request = new DiffCommandRequest(); private final DiffCommandRequest request = new DiffCommandRequest();

View File

@@ -39,10 +39,12 @@ import com.google.common.base.Objects;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.FeatureNotSupportedException;
import sonia.scm.cache.Cache; import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager; import sonia.scm.cache.CacheManager;
import sonia.scm.repository.Changeset; import sonia.scm.repository.Changeset;
import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.ChangesetPagingResult;
import sonia.scm.repository.Feature;
import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.PreProcessorUtil;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryCacheKey; import sonia.scm.repository.RepositoryCacheKey;
@@ -51,6 +53,7 @@ import sonia.scm.repository.spi.LogCommandRequest;
import java.io.IOException; import java.io.IOException;
import java.io.Serializable; import java.io.Serializable;
import java.util.Set;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
@@ -104,19 +107,20 @@ public final class LogCommandBuilder
/** /**
* Constructs a new {@link LogCommandBuilder}, this constructor should * Constructs a new {@link LogCommandBuilder}, this constructor should
* only be called from the {@link RepositoryService}. * only be called from the {@link RepositoryService}.
* * @param cacheManager cache manager
* @param cacheManager cache manager
* @param logCommand implementation of the {@link LogCommand} * @param logCommand implementation of the {@link LogCommand}
* @param repository repository to query * @param repository repository to query
* @param preProcessorUtil * @param preProcessorUtil
* @param supportedFeatures The supported features of the provider
*/ */
LogCommandBuilder(CacheManager cacheManager, LogCommand logCommand, LogCommandBuilder(CacheManager cacheManager, LogCommand logCommand,
Repository repository, PreProcessorUtil preProcessorUtil) Repository repository, PreProcessorUtil preProcessorUtil, Set<Feature> supportedFeatures)
{ {
this.cache = cacheManager.getCache(CACHE_NAME); this.cache = cacheManager.getCache(CACHE_NAME);
this.logCommand = logCommand; this.logCommand = logCommand;
this.repository = repository; this.repository = repository;
this.preProcessorUtil = preProcessorUtil; this.preProcessorUtil = preProcessorUtil;
this.supportedFeatures = supportedFeatures;
} }
//~--- methods -------------------------------------------------------------- //~--- methods --------------------------------------------------------------
@@ -397,7 +401,17 @@ public final class LogCommandBuilder
return this; return this;
} }
/**
* Compute the incoming changes of the branch set with {@link #setBranch(String)} in respect to the changeset given
* here. In other words: What changesets would be new to the ancestor changeset given here when the branch would
* be merged into it. Requires feature {@link sonia.scm.repository.Feature#INCOMING_REVISION}!
*
* @return {@code this}
*/
public LogCommandBuilder setAncestorChangeset(String ancestorChangeset) { public LogCommandBuilder setAncestorChangeset(String ancestorChangeset) {
if (!supportedFeatures.contains(Feature.INCOMING_REVISION)) {
throw new FeatureNotSupportedException(Feature.INCOMING_REVISION.name());
}
request.setAncestorChangeset(ancestorChangeset); request.setAncestorChangeset(ancestorChangeset);
return this; return this;
} }
@@ -527,6 +541,7 @@ public final class LogCommandBuilder
/** Field description */ /** Field description */
private final PreProcessorUtil preProcessorUtil; private final PreProcessorUtil preProcessorUtil;
private Set<Feature> supportedFeatures;
/** repository to query */ /** repository to query */
private final Repository repository; private final Repository repository;

View File

@@ -15,7 +15,7 @@ import sonia.scm.repository.spi.MergeCommandRequest;
* *
* To actually merge <code>feature_branch</code> into <code>integration_branch</code> do this: * To actually merge <code>feature_branch</code> into <code>integration_branch</code> do this:
* <pre><code> * <pre><code>
* repositoryService.gerMergeCommand() * repositoryService.getMergeCommand()
* .setBranchToMerge("feature_branch") * .setBranchToMerge("feature_branch")
* .setTargetBranch("integration_branch") * .setTargetBranch("integration_branch")
* .executeMerge(); * .executeMerge();
@@ -33,7 +33,7 @@ import sonia.scm.repository.spi.MergeCommandRequest;
* *
* To check whether they can be merged without conflicts beforehand do this: * To check whether they can be merged without conflicts beforehand do this:
* <pre><code> * <pre><code>
* repositoryService.gerMergeCommand() * repositoryService.getMergeCommand()
* .setBranchToMerge("feature_branch") * .setBranchToMerge("feature_branch")
* .setTargetBranch("integration_branch") * .setTargetBranch("integration_branch")
* .dryRun() * .dryRun()

View File

@@ -221,7 +221,7 @@ public final class RepositoryService implements Closeable {
logger.debug("create diff command for repository {}", logger.debug("create diff command for repository {}",
repository.getNamespaceAndName()); repository.getNamespaceAndName());
return new DiffCommandBuilder(provider.getDiffCommand()); return new DiffCommandBuilder(provider.getDiffCommand(), provider.getSupportedFeatures());
} }
/** /**
@@ -253,7 +253,7 @@ public final class RepositoryService implements Closeable {
repository.getNamespaceAndName()); repository.getNamespaceAndName());
return new LogCommandBuilder(cacheManager, provider.getLogCommand(), return new LogCommandBuilder(cacheManager, provider.getLogCommand(),
repository, preProcessorUtil); repository, preProcessorUtil, provider.getSupportedFeatures());
} }
/** /**
@@ -363,8 +363,8 @@ public final class RepositoryService implements Closeable {
* by the implementation of the repository service provider. * by the implementation of the repository service provider.
* @since 2.0.0 * @since 2.0.0
*/ */
public MergeCommandBuilder gerMergeCommand() { public MergeCommandBuilder getMergeCommand() {
logger.debug("create unbundle command for repository {}", logger.debug("create merge command for repository {}",
repository.getNamespaceAndName()); repository.getNamespaceAndName());
return new MergeCommandBuilder(provider.getMergeCommand()); return new MergeCommandBuilder(provider.getMergeCommand());

View File

@@ -80,8 +80,20 @@ public interface AccessToken {
*/ */
Date getExpiration(); Date getExpiration();
/**
* Returns refresh expiration of token.
*
* @return refresh expiration
*/
Optional<Date> getRefreshExpiration(); Optional<Date> getRefreshExpiration();
/**
* Returns id of the parent key.
*
* @return parent key id
*/
Optional<String> getParentKey();
/** /**
* Returns the scope of the token. The scope is able to reduce the permissions of the subject in the context of this * Returns the scope of the token. The scope is able to reduce the permissions of the subject in the context of this
* token. For example we could issue a token which can only be used to read a single repository. for more informations * token. For example we could issue a token which can only be used to read a single repository. for more informations

View File

@@ -0,0 +1,30 @@
package sonia.scm.security;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* Generates cookies and invalidates access token cookies.
*
* @author Sebastian Sdorra
* @since 2.0.0
*/
public interface AccessTokenCookieIssuer {
/**
* Creates a cookie for token authentication and attaches it to the response.
*
* @param request http servlet request
* @param response http servlet response
* @param accessToken access token
*/
void authenticate(HttpServletRequest request, HttpServletResponse response, AccessToken accessToken);
/**
* Invalidates the authentication cookie.
*
* @param request http servlet request
* @param response http servlet response
*/
void invalidate(HttpServletRequest request, HttpServletResponse response);
}

View File

@@ -164,7 +164,7 @@ public class DefaultCipherHandler implements CipherHandler {
String result = null; String result = null;
try { try {
byte[] encodedInput = Base64.getDecoder().decode(value); byte[] encodedInput = Base64.getUrlDecoder().decode(value);
byte[] salt = new byte[SALT_LENGTH]; byte[] salt = new byte[SALT_LENGTH];
byte[] encoded = new byte[encodedInput.length - SALT_LENGTH]; byte[] encoded = new byte[encodedInput.length - SALT_LENGTH];
@@ -221,7 +221,7 @@ public class DefaultCipherHandler implements CipherHandler {
System.arraycopy(salt, 0, result, 0, SALT_LENGTH); System.arraycopy(salt, 0, result, 0, SALT_LENGTH);
System.arraycopy(encodedInput, 0, result, SALT_LENGTH, System.arraycopy(encodedInput, 0, result, SALT_LENGTH,
result.length - SALT_LENGTH); result.length - SALT_LENGTH);
res = new String(Base64.getEncoder().encode(result), ENCODING); res = new String(Base64.getUrlEncoder().encode(result), ENCODING);
} catch (IOException | GeneralSecurityException ex) { } catch (IOException | GeneralSecurityException ex) {
throw new CipherException("could not encode string", ex); throw new CipherException("could not encode string", ex);
} }

View File

@@ -33,6 +33,10 @@
package sonia.scm.store; package sonia.scm.store;
import java.util.Optional;
import static java.util.Optional.ofNullable;
/** /**
* ConfigurationStore for configuration objects. <strong>Note:</strong> the default * ConfigurationStore for configuration objects. <strong>Note:</strong> the default
* implementation use JAXB to marshall the configuration objects. * implementation use JAXB to marshall the configuration objects.
@@ -50,7 +54,17 @@ public interface ConfigurationStore<T>
* *
* @return configuration object from store * @return configuration object from store
*/ */
public T get(); T get();
/**
* Returns the configuration object from store.
*
*
* @return configuration object from store
*/
default Optional<T> getOptional() {
return ofNullable(get());
}
//~--- set methods ---------------------------------------------------------- //~--- set methods ----------------------------------------------------------
@@ -60,5 +74,5 @@ public interface ConfigurationStore<T>
* *
* @param obejct configuration object to store * @param obejct configuration object to store
*/ */
public void set(T obejct); void set(T object);
} }

View File

@@ -32,6 +32,10 @@
package sonia.scm.store; package sonia.scm.store;
import java.util.Optional;
import static java.util.Optional.ofNullable;
/** /**
* Base class for {@link BlobStore} and {@link DataStore}. * Base class for {@link BlobStore} and {@link DataStore}.
* *
@@ -67,4 +71,16 @@ public interface MultiEntryStore<T> {
* @return item with the given id * @return item with the given id
*/ */
public T get(String id); public T get(String id);
/**
* Returns the item with the given id from the store.
*
*
* @param id id of the item to return
*
* @return item with the given id
*/
default Optional<T> getOptional(String id) {
return ofNullable(get(id));
}
} }

View File

@@ -1,12 +1,13 @@
package sonia.scm.user; package sonia.scm.user;
import sonia.scm.BadRequestException;
import sonia.scm.ContextEntry; import sonia.scm.ContextEntry;
import sonia.scm.ExceptionWithContext;
public class ChangePasswordNotAllowedException extends ExceptionWithContext { @SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
public class ChangePasswordNotAllowedException extends BadRequestException {
private static final String CODE = "9BR7qpDAe1"; private static final String CODE = "9BR7qpDAe1";
public static final String WRONG_USER_TYPE = "User of type %s are not allowed to change password"; public static final String WRONG_USER_TYPE = "Users of type %s are not allowed to change password";
public ChangePasswordNotAllowedException(ContextEntry.ContextBuilder context, String type) { public ChangePasswordNotAllowedException(ContextEntry.ContextBuilder context, String type) {
super(context.build(), String.format(WRONG_USER_TYPE, type)); super(context.build(), String.format(WRONG_USER_TYPE, type));

View File

@@ -1,9 +1,10 @@
package sonia.scm.user; package sonia.scm.user;
import sonia.scm.BadRequestException;
import sonia.scm.ContextEntry; import sonia.scm.ContextEntry;
import sonia.scm.ExceptionWithContext;
public class InvalidPasswordException extends ExceptionWithContext { @SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
public class InvalidPasswordException extends BadRequestException {
private static final String CODE = "8YR7aawFW1"; private static final String CODE = "8YR7aawFW1";

View File

@@ -0,0 +1,40 @@
package sonia.scm.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import static java.util.Collections.singletonMap;
import static sonia.scm.web.VndMediaType.REPOSITORY;
import static sonia.scm.web.VndMediaType.REPOSITORY_COLLECTION;
public abstract class AbstractRepositoryJsonEnricher extends JsonEnricherBase {
public AbstractRepositoryJsonEnricher(ObjectMapper objectMapper) {
super(objectMapper);
}
@Override
public void enrich(JsonEnricherContext context) {
if (resultHasMediaType(REPOSITORY, context)) {
JsonNode repositoryNode = context.getResponseEntity();
enrichRepositoryNode(repositoryNode);
} else if (resultHasMediaType(REPOSITORY_COLLECTION, context)) {
JsonNode repositoryCollectionNode = context.getResponseEntity().get("_embedded").withArray("repositories");
repositoryCollectionNode.elements().forEachRemaining(this::enrichRepositoryNode);
}
}
private void enrichRepositoryNode(JsonNode repositoryNode) {
String namespace = repositoryNode.get("namespace").asText();
String name = repositoryNode.get("name").asText();
enrichRepositoryNode(repositoryNode, namespace, name);
}
protected abstract void enrichRepositoryNode(JsonNode repositoryNode, String namespace, String name);
protected void addLink(JsonNode repositoryNode, String linkName, String link) {
JsonNode hrefNode = createObject(singletonMap("href", value(link)));
addPropertyNode(repositoryNode.get("_links"), linkName, hrefNode);
}
}

View File

@@ -15,7 +15,7 @@ public abstract class JsonEnricherBase implements JsonEnricher {
} }
protected boolean resultHasMediaType(String mediaType, JsonEnricherContext context) { protected boolean resultHasMediaType(String mediaType, JsonEnricherContext context) {
return mediaType.equals(context.getResponseMediaType().toString()); return mediaType.equalsIgnoreCase(context.getResponseMediaType().toString());
} }
protected JsonNode value(Object object) { protected JsonNode value(Object object) {

View File

@@ -41,6 +41,8 @@ public class VndMediaType {
public static final String PASSWORD_CHANGE = PREFIX + "passwordChange" + SUFFIX; public static final String PASSWORD_CHANGE = PREFIX + "passwordChange" + SUFFIX;
@SuppressWarnings("squid:S2068") @SuppressWarnings("squid:S2068")
public static final String PASSWORD_OVERWRITE = PREFIX + "passwordOverwrite" + SUFFIX; public static final String PASSWORD_OVERWRITE = PREFIX + "passwordOverwrite" + SUFFIX;
public static final String MERGE_RESULT = PREFIX + "mergeResult" + SUFFIX;
public static final String MERGE_COMMAND = PREFIX + "mergeCommand" + SUFFIX;
public static final String ME = PREFIX + "me" + SUFFIX; public static final String ME = PREFIX + "me" + SUFFIX;
public static final String SOURCE = PREFIX + "source" + SUFFIX; public static final String SOURCE = PREFIX + "source" + SUFFIX;

View File

@@ -0,0 +1,25 @@
package sonia.scm.xml;
import javax.xml.bind.annotation.adapters.XmlAdapter;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
/**
* JAXB adapter for {@link Instant} objects.
*
* @since 2.0.0
*/
public class XmlInstantAdapter extends XmlAdapter<String, Instant> {
@Override
public String marshal(Instant instant) {
return DateTimeFormatter.ISO_INSTANT.format(instant);
}
@Override
public Instant unmarshal(String text) {
TemporalAccessor parsed = DateTimeFormatter.ISO_INSTANT.parse(text);
return Instant.from(parsed);
}
}

View File

@@ -0,0 +1,107 @@
package sonia.scm.web;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.io.Resources;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.api.v2.resources.ScmPathInfoStore;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class AbstractRepositoryJsonEnricherTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private AbstractRepositoryJsonEnricher linkEnricher;
private JsonNode rootNode;
@BeforeEach
void globalSetUp() {
ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
pathInfoStore.set(() -> URI.create("/"));
linkEnricher = new AbstractRepositoryJsonEnricher(objectMapper) {
@Override
protected void enrichRepositoryNode(JsonNode repositoryNode, String namespace, String name) {
addLink(repositoryNode, "new-link", "/somewhere");
}
};
}
@Test
void shouldEnrichRepositories() throws IOException {
URL resource = Resources.getResource("sonia/scm/repository/repository-001.json");
rootNode = objectMapper.readTree(resource);
JsonEnricherContext context = new JsonEnricherContext(
URI.create("/"),
MediaType.valueOf(VndMediaType.REPOSITORY),
rootNode
);
linkEnricher.enrich(context);
String configLink = context.getResponseEntity()
.get("_links")
.get("new-link")
.get("href")
.asText();
assertThat(configLink).isEqualTo("/somewhere");
}
@Test
void shouldEnrichAllRepositories() throws IOException {
URL resource = Resources.getResource("sonia/scm/repository/repository-collection-001.json");
rootNode = objectMapper.readTree(resource);
JsonEnricherContext context = new JsonEnricherContext(
URI.create("/"),
MediaType.valueOf(VndMediaType.REPOSITORY_COLLECTION),
rootNode
);
linkEnricher.enrich(context);
context.getResponseEntity()
.get("_embedded")
.withArray("repositories")
.elements()
.forEachRemaining(node -> {
String configLink = node
.get("_links")
.get("new-link")
.get("href")
.asText();
assertThat(configLink).isEqualTo("/somewhere");
});
}
@Test
void shouldNotModifyObjectsWithUnsupportedMediaType() throws IOException {
URL resource = Resources.getResource("sonia/scm/repository/repository-001.json");
rootNode = objectMapper.readTree(resource);
JsonEnricherContext context = new JsonEnricherContext(
URI.create("/"),
MediaType.valueOf(VndMediaType.USER),
rootNode
);
linkEnricher.enrich(context);
boolean hasNewPullRequestLink = context.getResponseEntity()
.get("_links")
.has("new-link");
assertThat(hasNewPullRequestLink).isFalse();
}
}

View File

@@ -23,6 +23,14 @@ public class JsonEnricherBaseTest {
assertThat(enricher.resultHasMediaType(MediaType.APPLICATION_XML, context)).isFalse(); assertThat(enricher.resultHasMediaType(MediaType.APPLICATION_XML, context)).isFalse();
} }
@Test
public void testResultHasMediaTypeWithCamelCaseMediaType() {
String mediaType = "application/hitchhikersGuideToTheGalaxy";
JsonEnricherContext context = new JsonEnricherContext(null, MediaType.valueOf(mediaType), null);
assertThat(enricher.resultHasMediaType(mediaType, context)).isTrue();
}
@Test @Test
public void testAppendLink() { public void testAppendLink() {
ObjectNode root = objectMapper.createObjectNode(); ObjectNode root = objectMapper.createObjectNode();

View File

@@ -0,0 +1,47 @@
package sonia.scm.xml;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junitpioneer.jupiter.TempDirectory;
import javax.xml.bind.JAXB;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.nio.file.Path;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(TempDirectory.class)
class XmlInstantAdapterTest {
@Test
void shouldMarshalAndUnmarshalInstant(@TempDirectory.TempDir Path tempDirectory) {
Path path = tempDirectory.resolve("instant.xml");
Instant instant = Instant.now();
InstantObject object = new InstantObject(instant);
JAXB.marshal(object, path.toFile());
InstantObject unmarshaled = JAXB.unmarshal(path.toFile(), InstantObject.class);
assertEquals(instant, unmarshaled.instant);
}
@XmlRootElement(name = "instant-object")
@XmlAccessorType(XmlAccessType.FIELD)
public static class InstantObject {
@XmlJavaTypeAdapter(XmlInstantAdapter.class)
private Instant instant;
public InstantObject() {
}
InstantObject(Instant instant) {
this.instant = instant;
}
}
}

View File

@@ -0,0 +1,42 @@
{
"creationDate": "2018-11-09T09:48:32.732Z",
"description": "Handling static webresources made easy",
"healthCheckFailures": [],
"lastModified": "2018-11-09T09:49:20.973Z",
"namespace": "scmadmin",
"name": "web-resources",
"archived": false,
"type": "git",
"_links": {
"self": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
},
"delete": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
},
"update": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
},
"permissions": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/"
},
"protocol": [
{
"href": "http://localhost:8081/scm/repo/scmadmin/web-resources",
"name": "http"
}
],
"tags": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/"
},
"branches": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/"
},
"changesets": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/"
},
"sources": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/"
}
}
}

View File

@@ -0,0 +1,106 @@
{
"page": 0,
"pageTotal": 1,
"_links": {
"self": {
"href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10"
},
"first": {
"href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10"
},
"last": {
"href": "http://localhost:8081/scm/api/v2/repositories/?page=0&pageSize=10"
},
"create": {
"href": "http://localhost:8081/scm/api/v2/repositories/"
}
},
"_embedded": {
"repositories": [
{
"creationDate": "2018-11-09T09:48:32.732Z",
"description": "Handling static webresources made easy",
"healthCheckFailures": [],
"lastModified": "2018-11-09T09:49:20.973Z",
"namespace": "scmadmin",
"name": "web-resources",
"archived": false,
"type": "git",
"_links": {
"self": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
},
"delete": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
},
"update": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
},
"permissions": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/"
},
"protocol": [
{
"href": "http://localhost:8081/scm/repo/scmadmin/web-resources",
"name": "http"
}
],
"tags": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/"
},
"branches": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/"
},
"changesets": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/"
},
"sources": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/"
}
}
},
{
"creationDate": "2018-11-09T09:48:32.732Z",
"description": "Handling static webresources made easy",
"healthCheckFailures": [],
"lastModified": "2018-11-09T09:49:20.973Z",
"namespace": "scmadmin",
"name": "web-resources",
"archived": false,
"type": "git",
"_links": {
"self": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
},
"delete": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
},
"update": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources"
},
"permissions": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/permissions/"
},
"protocol": [
{
"href": "http://localhost:8081/scm/repo/scmadmin/web-resources",
"name": "http"
}
],
"tags": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/tags/"
},
"branches": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/branches/"
},
"changesets": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/changesets/"
},
"sources": {
"href": "http://localhost:8081/scm/api/v2/repositories/scmadmin/web-resources/sources/"
}
}
}
]
}
}

View File

@@ -58,8 +58,6 @@ public abstract class FileBasedStoreFactory {
private RepositoryLocationResolver repositoryLocationResolver; private RepositoryLocationResolver repositoryLocationResolver;
private Store store; private Store store;
private File storeDirectory;
protected FileBasedStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, Store store) { protected FileBasedStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, Store store) {
this.contextProvider = contextProvider; this.contextProvider = contextProvider;
this.repositoryLocationResolver = repositoryLocationResolver; this.repositoryLocationResolver = repositoryLocationResolver;
@@ -75,17 +73,16 @@ public abstract class FileBasedStoreFactory {
} }
protected File getStoreLocation(String name, Class type, Repository repository) { protected File getStoreLocation(String name, Class type, Repository repository) {
if (storeDirectory == null) { File storeDirectory;
if (repository != null) { if (repository != null) {
LOG.debug("create store with type: {}, name: {} and repository: {}", type, name, repository.getNamespaceAndName()); LOG.debug("create store with type: {}, name: {} and repository: {}", type, name, repository.getNamespaceAndName());
storeDirectory = this.getStoreDirectory(store, repository); storeDirectory = this.getStoreDirectory(store, repository);
} else { } else {
LOG.debug("create store with type: {} and name: {} ", type, name); LOG.debug("create store with type: {} and name: {} ", type, name);
storeDirectory = this.getStoreDirectory(store); storeDirectory = this.getStoreDirectory(store);
}
IOUtil.mkdirs(storeDirectory);
} }
return new File(this.storeDirectory, name); IOUtil.mkdirs(storeDirectory);
return new File(storeDirectory, name);
} }
/** /**

View File

@@ -8,6 +8,7 @@ public enum Store {
BLOB("blob"); BLOB("blob");
private static final String GLOBAL_STORE_BASE_DIRECTORY = "var"; private static final String GLOBAL_STORE_BASE_DIRECTORY = "var";
private static final String STORE_DIRECTORY = "store";
private String directory; private String directory;
@@ -17,17 +18,17 @@ public enum Store {
} }
/** /**
* Get the relkative store directory path to be stored in the repository root * Get the relative store directory path to be stored in the repository root
* <p> * <p>
* The repository store directories are: * The repository store directories are:
* repo_base_dir/config/ * repo_base_dir/store/config/
* repo_base_dir/blob/ * repo_base_dir/store/blob/
* repo_base_dir/data/ * repo_base_dir/store/data/
* *
* @return the relative store directory path to be stored in the repository root * @return the relative store directory path to be stored in the repository root
*/ */
public String getRepositoryStoreDirectory() { public String getRepositoryStoreDirectory() {
return directory; return STORE_DIRECTORY + File.separator + directory;
} }
/** /**

View File

@@ -12,6 +12,6 @@
"@scm-manager/ui-extensions": "^0.1.1" "@scm-manager/ui-extensions": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.21" "@scm-manager/ui-bundler": "^0.0.24"
} }
} }

View File

@@ -48,6 +48,7 @@ import org.eclipse.jgit.lib.RepositoryCache;
import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.spi.HookEventFacade; import sonia.scm.repository.spi.HookEventFacade;
import sonia.scm.web.CollectingPackParserListener;
import sonia.scm.web.GitReceiveHook; import sonia.scm.web.GitReceiveHook;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
@@ -64,10 +65,10 @@ public class ScmTransportProtocol extends TransportProtocol
{ {
/** Field description */ /** Field description */
private static final String NAME = "scm"; public static final String NAME = "scm";
/** Field description */ /** Field description */
private static final Set<String> SCHEMES = ImmutableSet.of("scm"); private static final Set<String> SCHEMES = ImmutableSet.of(NAME);
//~--- constructors --------------------------------------------------------- //~--- constructors ---------------------------------------------------------
@@ -234,6 +235,8 @@ public class ScmTransportProtocol extends TransportProtocol
pack.setPreReceiveHook(hook); pack.setPreReceiveHook(hook);
pack.setPostReceiveHook(hook); pack.setPostReceiveHook(hook);
CollectingPackParserListener.set(pack);
} }
return pack; return pack;

View File

@@ -43,7 +43,6 @@ import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.SCMContextProvider; import sonia.scm.SCMContextProvider;
import sonia.scm.io.FileSystem;
import sonia.scm.plugin.Extension; import sonia.scm.plugin.Extension;
import sonia.scm.repository.spi.GitRepositoryServiceProvider; import sonia.scm.repository.spi.GitRepositoryServiceProvider;
import sonia.scm.schedule.Scheduler; import sonia.scm.schedule.Scheduler;
@@ -97,7 +96,7 @@ public class GitRepositoryHandler
private final GitWorkdirFactory workdirFactory; private final GitWorkdirFactory workdirFactory;
private Task task; private Task task;
//~--- constructors --------------------------------------------------------- //~--- constructors ---------------------------------------------------------
@Inject @Inject
@@ -126,7 +125,7 @@ public class GitRepositoryHandler
scheduleGc(config.getGcExpression()); scheduleGc(config.getGcExpression());
super.setConfig(config); super.setConfig(config);
} }
private void scheduleGc(String expression) private void scheduleGc(String expression)
{ {
synchronized (LOCK){ synchronized (LOCK){
@@ -142,7 +141,7 @@ public class GitRepositoryHandler
} }
} }
} }
/** /**
* Method description * Method description
* *

View File

@@ -4,8 +4,10 @@ import com.google.common.base.Strings;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.MergeCommand.FastForwardMode;
import org.eclipse.jgit.api.MergeResult; import org.eclipse.jgit.api.MergeResult;
import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.RefNotFoundException;
import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.merge.MergeStrategy;
@@ -15,6 +17,7 @@ import org.slf4j.LoggerFactory;
import sonia.scm.repository.GitWorkdirFactory; import sonia.scm.repository.GitWorkdirFactory;
import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Person; import sonia.scm.repository.Person;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.repository.api.MergeCommandResult;
import sonia.scm.repository.api.MergeDryRunCommandResult; import sonia.scm.repository.api.MergeDryRunCommandResult;
import sonia.scm.user.User; import sonia.scm.user.User;
@@ -22,6 +25,9 @@ import sonia.scm.user.User;
import java.io.IOException; import java.io.IOException;
import java.text.MessageFormat; import java.text.MessageFormat;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound;
public class GitMergeCommand extends AbstractGitCommand implements MergeCommand { public class GitMergeCommand extends AbstractGitCommand implements MergeCommand {
private static final Logger logger = LoggerFactory.getLogger(GitMergeCommand.class); private static final Logger logger = LoggerFactory.getLogger(GitMergeCommand.class);
@@ -40,6 +46,8 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
@Override @Override
public MergeCommandResult merge(MergeCommandRequest request) { public MergeCommandResult merge(MergeCommandRequest request) {
RepositoryPermissions.push(context.getRepository().getId()).check();
try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) { try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) {
Repository repository = workingCopy.get(); Repository repository = workingCopy.get();
logger.debug("cloned repository to folder {}", repository.getWorkTree()); logger.debug("cloned repository to folder {}", repository.getWorkTree());
@@ -88,20 +96,43 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
} }
} }
private void checkOutTargetBranch() { private void checkOutTargetBranch() throws IOException {
try { try {
clone.checkout().setName(target).call(); clone.checkout().setName(target).call();
} catch (RefNotFoundException e) {
logger.trace("could not checkout target branch {} for merge directly; trying to create local branch", target, e);
checkOutTargetAsNewLocalBranch();
} catch (GitAPIException e) { } catch (GitAPIException e) {
throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge: " + target, e); throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge: " + target, e);
} }
} }
private void checkOutTargetAsNewLocalBranch() throws IOException {
try {
ObjectId targetRevision = resolveRevision(target);
if (targetRevision == null) {
throw notFound(entity("revision", target).in(context.getRepository()));
}
clone.checkout().setStartPoint(targetRevision.getName()).setName(target).setCreateBranch(true).call();
} catch (RefNotFoundException e) {
logger.debug("could not checkout target branch {} for merge as local branch", target, e);
throw notFound(entity("revision", target).in(context.getRepository()));
} catch (GitAPIException e) {
throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge as local branch: " + target, e);
}
}
private MergeResult doMergeInClone() throws IOException { private MergeResult doMergeInClone() throws IOException {
MergeResult result; MergeResult result;
try { try {
ObjectId sourceRevision = resolveRevision(toMerge);
if (sourceRevision == null) {
throw notFound(entity("revision", toMerge).in(context.getRepository()));
}
result = clone.merge() result = clone.merge()
.setFastForward(FastForwardMode.NO_FF)
.setCommit(false) // we want to set the author manually .setCommit(false) // we want to set the author manually
.include(toMerge, resolveRevision(toMerge)) .include(toMerge, sourceRevision)
.call(); .call();
} catch (GitAPIException e) { } catch (GitAPIException e) {
throw new InternalRepositoryException(context.getRepository(), "could not merge branch " + toMerge + " into " + target, e); throw new InternalRepositoryException(context.getRepository(), "could not merge branch " + toMerge + " into " + target, e);
@@ -113,10 +144,12 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
logger.debug("merged branch {} into {}", toMerge, target); logger.debug("merged branch {} into {}", toMerge, target);
Person authorToUse = determineAuthor(); Person authorToUse = determineAuthor();
try { try {
clone.commit() if (!clone.status().call().isClean()) {
.setAuthor(authorToUse.getName(), authorToUse.getMail()) clone.commit()
.setMessage(MessageFormat.format(determineMessageTemplate(), toMerge, target)) .setAuthor(authorToUse.getName(), authorToUse.getMail())
.call(); .setMessage(MessageFormat.format(determineMessageTemplate(), toMerge, target))
.call();
}
} catch (GitAPIException e) { } catch (GitAPIException e) {
throw new InternalRepositoryException(context.getRepository(), "could not commit merge between branch " + toMerge + " and " + target, e); throw new InternalRepositoryException(context.getRepository(), "could not commit merge between branch " + toMerge + " and " + target, e);
} }
@@ -147,7 +180,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
try { try {
clone.push().call(); clone.push().call();
} catch (GitAPIException e) { } catch (GitAPIException e) {
throw new InternalRepositoryException(context.getRepository(), "could not push merged branch " + toMerge + " to origin", e); throw new InternalRepositoryException(context.getRepository(), "could not push merged branch " + target + " to origin", e);
} }
logger.debug("pushed merged branch {}", target); logger.debug("pushed merged branch {}", target);
} }

View File

@@ -34,11 +34,13 @@
package sonia.scm.repository.spi; package sonia.scm.repository.spi;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import sonia.scm.repository.Feature;
import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.api.Command; import sonia.scm.repository.api.Command;
import java.io.IOException; import java.io.IOException;
import java.util.EnumSet;
import java.util.Set; import java.util.Set;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
@@ -66,6 +68,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
Command.PULL, Command.PULL,
Command.MERGE Command.MERGE
); );
protected static final Set<Feature> FEATURES = EnumSet.of(Feature.INCOMING_REVISION);
//J+ //J+
//~--- constructors --------------------------------------------------------- //~--- constructors ---------------------------------------------------------
@@ -246,6 +249,10 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
return new GitMergeCommand(context, repository, handler.getWorkdirFactory()); return new GitMergeCommand(context, repository, handler.getWorkdirFactory());
} }
@Override
public Set<Feature> getSupportedFeatures() {
return FEATURES;
}
//~--- fields --------------------------------------------------------------- //~--- fields ---------------------------------------------------------------
/** Field description */ /** Field description */

View File

@@ -3,6 +3,7 @@ package sonia.scm.repository.spi;
import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.ScmTransportProtocol;
import org.eclipse.jgit.util.FileUtils; import org.eclipse.jgit.util.FileUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -45,12 +46,16 @@ public class SimpleGitWorkdirFactory implements GitWorkdirFactory {
protected Repository cloneRepository(File bareRepository, File target) throws GitAPIException { protected Repository cloneRepository(File bareRepository, File target) throws GitAPIException {
return Git.cloneRepository() return Git.cloneRepository()
.setURI(bareRepository.getAbsolutePath()) .setURI(createScmTransportProtocolUri(bareRepository))
.setDirectory(target) .setDirectory(target)
.call() .call()
.getRepository(); .getRepository();
} }
private String createScmTransportProtocolUri(File bareRepository) {
return ScmTransportProtocol.NAME + "://" + bareRepository.getAbsolutePath();
}
private void close(Repository repository) { private void close(Repository repository) {
repository.close(); repository.close();
try { try {

View File

@@ -0,0 +1,59 @@
//@flow
import React from "react";
import type { Repository } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
type Props = {
repository: Repository,
target: string,
source: string,
t: string => string
};
class GitMergeInformation extends React.Component<Props> {
render() {
const { source, target, t } = this.props;
return (
<div>
<h4>{t("scm-git-plugin.information.merge.heading")}</h4>
{t("scm-git-plugin.information.merge.checkout")}
<pre>
<code>git checkout {target}</code>
</pre>
{t("scm-git-plugin.information.merge.update")}
<pre>
<code>
git pull
</code>
</pre>
{t("scm-git-plugin.information.merge.merge")}
<pre>
<code>
git merge {source}
</code>
</pre>
{t("scm-git-plugin.information.merge.resolve")}
<pre>
<code>
git add &lt;conflict file&gt;
</code>
</pre>
{t("scm-git-plugin.information.merge.commit")}
<pre>
<code>
git commit -m "Merge {source} into {target}"
</code>
</pre>
{t("scm-git-plugin.information.merge.push")}
<pre>
<code>
git push
</code>
</pre>
</div>
);
}
}
export default translate("plugins")(GitMergeInformation);

View File

@@ -5,6 +5,7 @@ import GitAvatar from "./GitAvatar";
import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components"; import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components";
import GitGlobalConfiguration from "./GitGlobalConfiguration"; import GitGlobalConfiguration from "./GitGlobalConfiguration";
import GitMergeInformation from "./GitMergeInformation";
// repository // repository
@@ -13,6 +14,7 @@ const gitPredicate = (props: Object) => {
}; };
binder.bind("repos.repository-details.information", ProtocolInformation, gitPredicate); binder.bind("repos.repository-details.information", ProtocolInformation, gitPredicate);
binder.bind("repos.repository-merge.information", GitMergeInformation, gitPredicate);
binder.bind("repos.repository-avatar", GitAvatar, gitPredicate); binder.bind("repos.repository-avatar", GitAvatar, gitPredicate);
// global config // global config

View File

@@ -3,7 +3,16 @@
"information": { "information": {
"clone" : "Clone the repository", "clone" : "Clone the repository",
"create" : "Create a new repository", "create" : "Create a new repository",
"replace" : "Push an existing repository" "replace" : "Push an existing repository",
"merge": {
"heading": "How to merge source branch into target branch",
"checkout": "1. Make sure your workspace is clean and checkout target branch",
"update": "2. Update workspace",
"merge": "3. Merge source branch",
"resolve": "4. Resolve merge conflicts and add corrected files to index",
"commit": "5. Commit",
"push": "6. Push your merge"
}
}, },
"config": { "config": {
"link": "Git", "link": "Git",

View File

@@ -6,20 +6,33 @@ import org.apache.shiro.subject.SimplePrincipalCollection;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.transport.ScmTransportProtocol;
import org.eclipse.jgit.transport.Transport;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.Person; import sonia.scm.repository.Person;
import sonia.scm.repository.PreProcessorUtil;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.api.HookContextFactory;
import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.repository.api.MergeCommandResult;
import sonia.scm.user.User; import sonia.scm.user.User;
import java.io.IOException; import java.io.IOException;
import static com.google.inject.util.Providers.of;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini") @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret")
public class GitMergeCommandTest extends AbstractGitCommandTestBase { public class GitMergeCommandTest extends AbstractGitCommandTestBase {
private static final String REALM = "AdminRealm"; private static final String REALM = "AdminRealm";
@@ -27,6 +40,27 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase {
@Rule @Rule
public ShiroRule shiro = new ShiroRule(); public ShiroRule shiro = new ShiroRule();
private ScmTransportProtocol scmTransportProtocol;
@Before
public void bindScmProtocol() {
HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class));
RepositoryManager repositoryManager = mock(RepositoryManager.class);
HookEventFacade hookEventFacade = new HookEventFacade(of(repositoryManager), hookContextFactory);
GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class);
scmTransportProtocol = new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler));
Transport.register(scmTransportProtocol);
when(gitRepositoryHandler.getRepositoryId(any())).thenReturn("1");
when(repositoryManager.get("1")).thenReturn(new sonia.scm.repository.Repository());
}
@After
public void unregisterScmProtocol() {
Transport.unregister(scmTransportProtocol);
}
@Test @Test
public void shouldDetectMergeableBranches() { public void shouldDetectMergeableBranches() {
GitMergeCommand command = createCommand(); GitMergeCommand command = createCommand();
@@ -77,6 +111,30 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase {
assertThat(new String(contentOfFileB)).isEqualTo("b\ncontent from branch\n"); assertThat(new String(contentOfFileB)).isEqualTo("b\ncontent from branch\n");
} }
@Test
public void shouldNotMergeTwice() throws IOException, GitAPIException {
GitMergeCommand command = createCommand();
MergeCommandRequest request = new MergeCommandRequest();
request.setTargetBranch("master");
request.setBranchToMerge("mergeable");
request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det"));
MergeCommandResult mergeCommandResult = command.merge(request);
assertThat(mergeCommandResult.isSuccess()).isTrue();
Repository repository = createContext().open();
ObjectId firstMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId();
MergeCommandResult secondMergeCommandResult = command.merge(request);
assertThat(secondMergeCommandResult.isSuccess()).isTrue();
ObjectId secondMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId();
assertThat(secondMergeCommit).isEqualTo(firstMergeCommit);
}
@Test @Test
public void shouldUseConfiguredCommitMessageTemplate() throws IOException, GitAPIException { public void shouldUseConfiguredCommitMessageTemplate() throws IOException, GitAPIException {
GitMergeCommand command = createCommand(); GitMergeCommand command = createCommand();
@@ -111,11 +169,14 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase {
} }
@Test @Test
@SubjectAware(username = "admin", password = "secret")
public void shouldTakeAuthorFromSubjectIfNotSet() throws IOException, GitAPIException { public void shouldTakeAuthorFromSubjectIfNotSet() throws IOException, GitAPIException {
SimplePrincipalCollection principals = new SimplePrincipalCollection();
principals.add("admin", REALM);
principals.add( new User("dirk", "Dirk Gently", "dirk@holistic.det"), REALM);
shiro.setSubject( shiro.setSubject(
new Subject.Builder() new Subject.Builder()
.principals(new SimplePrincipalCollection(new User("dirk", "Dirk Gently", "dirk@holistic.det"), REALM)) .principals(principals)
.authenticated(true)
.buildSubject()); .buildSubject());
GitMergeCommand command = createCommand(); GitMergeCommand command = createCommand();
MergeCommandRequest request = new MergeCommandRequest(); MergeCommandRequest request = new MergeCommandRequest();
@@ -133,6 +194,32 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase {
assertThat(mergeAuthor.getEmailAddress()).isEqualTo("dirk@holistic.det"); assertThat(mergeAuthor.getEmailAddress()).isEqualTo("dirk@holistic.det");
} }
@Test
public void shouldMergeIntoNotDefaultBranch() throws IOException, GitAPIException {
GitMergeCommand command = createCommand();
MergeCommandRequest request = new MergeCommandRequest();
request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det"));
request.setTargetBranch("mergeable");
request.setBranchToMerge("master");
MergeCommandResult mergeCommandResult = command.merge(request);
Repository repository = createContext().open();
assertThat(mergeCommandResult.isSuccess()).isTrue();
Iterable<RevCommit> commits = new Git(repository).log().add(repository.resolve("mergeable")).setMaxCount(1).call();
RevCommit mergeCommit = commits.iterator().next();
PersonIdent mergeAuthor = mergeCommit.getAuthorIdent();
String message = mergeCommit.getFullMessage();
assertThat(mergeAuthor.getName()).isEqualTo("Dirk Gently");
assertThat(mergeAuthor.getEmailAddress()).isEqualTo("dirk@holistic.det");
assertThat(message).contains("master", "mergeable");
// We expect the merge result of file b.txt here by looking up the sha hash of its content.
// If the file is missing (aka not merged correctly) this will throw a MissingObjectException:
byte[] contentOfFileB = repository.open(repository.resolve("9513e9c76e73f3e562fd8e4c909d0607113c77c6")).getBytes();
assertThat(new String(contentOfFileB)).isEqualTo("b\ncontent from branch\n");
}
private GitMergeCommand createCommand() { private GitMergeCommand createCommand() {
return new GitMergeCommand(createContext(), repository, new SimpleGitWorkdirFactory()); return new GitMergeCommand(createContext(), repository, new SimpleGitWorkdirFactory());
} }

View File

@@ -2,14 +2,23 @@ package sonia.scm.repository.spi;
import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.ScmTransportProtocol;
import org.eclipse.jgit.transport.Transport;
import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.PreProcessorUtil;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.api.HookContextFactory;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import static com.google.inject.util.Providers.of;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy; import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -18,6 +27,14 @@ public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase {
@Rule @Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder(); public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Before
public void bindScmProtocol() {
HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class));
HookEventFacade hookEventFacade = new HookEventFacade(of(mock(RepositoryManager.class)), hookContextFactory);
GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class);
Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler)));
}
@Test @Test
public void emptyPoolShouldCreateNewWorkdir() throws IOException { public void emptyPoolShouldCreateNewWorkdir() throws IOException {
SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(temporaryFolder.newFolder()); SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(temporaryFolder.newFolder());

View File

@@ -707,9 +707,9 @@
version "0.0.2" version "0.0.2"
resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85"
"@scm-manager/ui-bundler@^0.0.21": "@scm-manager/ui-bundler@^0.0.24":
version "0.0.21" version "0.0.24"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e"
dependencies: dependencies:
"@babel/core" "^7.0.0" "@babel/core" "^7.0.0"
"@babel/plugin-proposal-class-properties" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0"

View File

@@ -9,6 +9,6 @@
"@scm-manager/ui-extensions": "^0.1.1" "@scm-manager/ui-extensions": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.21" "@scm-manager/ui-bundler": "^0.0.24"
} }
} }

View File

@@ -641,9 +641,9 @@
version "0.0.2" version "0.0.2"
resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85"
"@scm-manager/ui-bundler@^0.0.21": "@scm-manager/ui-bundler@^0.0.24":
version "0.0.21" version "0.0.24"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e"
dependencies: dependencies:
"@babel/core" "^7.0.0" "@babel/core" "^7.0.0"
"@babel/plugin-proposal-class-properties" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0"

View File

@@ -9,6 +9,6 @@
"@scm-manager/ui-extensions": "^0.1.1" "@scm-manager/ui-extensions": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.21" "@scm-manager/ui-bundler": "^0.0.24"
} }
} }

View File

@@ -641,9 +641,9 @@
version "0.0.2" version "0.0.2"
resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85"
"@scm-manager/ui-bundler@^0.0.21": "@scm-manager/ui-bundler@^0.0.24":
version "0.0.21" version "0.0.24"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e"
dependencies: dependencies:
"@babel/core" "^7.0.0" "@babel/core" "^7.0.0"
"@babel/plugin-proposal-class-properties" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0"

View File

@@ -42,8 +42,20 @@ package sonia.scm.store;
*/ */
public class InMemoryConfigurationStoreFactory implements ConfigurationStoreFactory { public class InMemoryConfigurationStoreFactory implements ConfigurationStoreFactory {
private ConfigurationStore store;
public InMemoryConfigurationStoreFactory() {
}
public InMemoryConfigurationStoreFactory(ConfigurationStore store) {
this.store = store;
}
@Override @Override
public ConfigurationStore getStore(TypedStoreParameters storeParameters) { public ConfigurationStore getStore(TypedStoreParameters storeParameters) {
if (store != null) {
return store;
}
return new InMemoryConfigurationStore<>(); return new InMemoryConfigurationStore<>();
} }
} }

View File

@@ -0,0 +1,53 @@
package sonia.scm.store;
import sonia.scm.security.KeyGenerator;
import sonia.scm.security.UUIDKeyGenerator;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* In memory store implementation of {@link DataStore}.
*
* @author Sebastian Sdorra
*
* @param <T> type of stored object
*/
public class InMemoryDataStore<T> implements DataStore<T> {
private final Map<String, T> store = new HashMap<>();
private KeyGenerator generator = new UUIDKeyGenerator();
@Override
public String put(T item) {
String key = generator.createKey();
store.put(key, item);
return key;
}
@Override
public void put(String id, T item) {
store.put(id, item);
}
@Override
public Map<String, T> getAll() {
return Collections.unmodifiableMap(store);
}
@Override
public void clear() {
store.clear();
}
@Override
public void remove(String id) {
store.remove(id);
}
@Override
public T get(String id) {
return store.get(id);
}
}

View File

@@ -0,0 +1,26 @@
package sonia.scm.store;
/**
* In memory configuration store factory for testing purposes.
*
* @author Sebastian Sdorra
*/
public class InMemoryDataStoreFactory implements DataStoreFactory {
private InMemoryDataStore store;
public InMemoryDataStoreFactory() {
}
public InMemoryDataStoreFactory(InMemoryDataStore store) {
this.store = store;
}
@Override
public <T> DataStore<T> getStore(TypedStoreParameters<T> storeParameters) {
if (store != null) {
return store;
}
return new InMemoryDataStore<>();
}
}

View File

@@ -14,7 +14,7 @@
"eslint-fix": "eslint src --fix" "eslint-fix": "eslint src --fix"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.21", "@scm-manager/ui-bundler": "^0.0.24",
"create-index": "^2.3.0", "create-index": "^2.3.0",
"enzyme": "^3.5.0", "enzyme": "^3.5.0",
"enzyme-adapter-react-16": "^1.3.1", "enzyme-adapter-react-16": "^1.3.1",
@@ -34,7 +34,9 @@
"react-dom": "^16.5.2", "react-dom": "^16.5.2",
"react-i18next": "^7.11.0", "react-i18next": "^7.11.0",
"react-jss": "^8.6.1", "react-jss": "^8.6.1",
"react-router-dom": "^4.3.1" "react-router-dom": "^4.3.1",
"react-select": "^2.1.2",
"diff2html": "^2.5.0"
}, },
"browserify": { "browserify": {
"transform": [ "transform": [

View File

@@ -0,0 +1,73 @@
// @flow
import React from "react";
import { AsyncCreatable } from "react-select";
import type { AutocompleteObject, SelectValue } from "@scm-manager/ui-types";
import LabelWithHelpIcon from "./forms/LabelWithHelpIcon";
type Props = {
loadSuggestions: string => Promise<AutocompleteObject>,
valueSelected: SelectValue => void,
label: string,
helpText?: string,
value?: SelectValue,
placeholder: string,
loadingMessage: string,
noOptionsMessage: string
};
type State = {};
class Autocomplete extends React.Component<Props, State> {
static defaultProps = {
placeholder: "Type here",
loadingMessage: "Loading...",
noOptionsMessage: "No suggestion available"
};
handleInputChange = (newValue: SelectValue) => {
this.props.valueSelected(newValue);
};
// We overwrite this to avoid running into a bug (https://github.com/JedWatson/react-select/issues/2944)
isValidNewOption = (inputValue: string, selectValue: SelectValue, selectOptions: SelectValue[]) => {
const isNotDuplicated = !selectOptions
.map(option => option.label)
.includes(inputValue);
const isNotEmpty = inputValue !== "";
return isNotEmpty && isNotDuplicated;
};
render() {
const { label, helpText, value, placeholder, loadingMessage, noOptionsMessage, loadSuggestions } = this.props;
return (
<div className="field">
<LabelWithHelpIcon label={label} helpText={helpText} />
<div className="control">
<AsyncCreatable
cacheOptions
loadOptions={loadSuggestions}
onChange={this.handleInputChange}
value={value}
placeholder={placeholder}
loadingMessage={() => loadingMessage}
noOptionsMessage={() => noOptionsMessage}
isValidNewOption={this.isValidNewOption}
onCreateOption={value => {
this.handleInputChange({
label: value,
value: { id: value, displayName: value }
});
}}
/>
</div>
</div>
);
}
}
export default Autocomplete;

View File

@@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import Notification from "./Notification"; import Notification from "./Notification";
import {UNAUTHORIZED_ERROR} from "./apiclient";
type Props = { type Props = {
t: string => string, t: string => string,
@@ -9,16 +10,27 @@ type Props = {
}; };
class ErrorNotification extends React.Component<Props> { class ErrorNotification extends React.Component<Props> {
render() { render() {
const { t, error } = this.props; const { t, error } = this.props;
if (error) { if (error) {
return ( if (error === UNAUTHORIZED_ERROR) {
<Notification type="danger"> return (
<strong>{t("error-notification.prefix")}:</strong> {error.message} <Notification type="danger">
</Notification> <strong>{t("error-notification.prefix")}:</strong> {t("error-notification.timeout")}
); {" "}
<a href="javascript:window.location.reload(true)">{t("error-notification.loginLink")}</a>
</Notification>
);
} else {
return (
<Notification type="danger">
<strong>{t("error-notification.prefix")}:</strong> {error.message}
</Notification>
);
}
} }
return ""; return null;
} }
} }

View File

@@ -1,12 +1,11 @@
// @flow // @flow
import React from "react"; import React from "react";
import {mount, shallow} from "enzyme"; import { mount, shallow } from "enzyme";
import "./tests/enzyme"; import "./tests/enzyme";
import "./tests/i18n"; import "./tests/i18n";
import ReactRouterEnzymeContext from "react-router-enzyme-context"; import ReactRouterEnzymeContext from "react-router-enzyme-context";
import Paginator from "./Paginator"; import Paginator from "./Paginator";
// TODO: Fix tests
xdescribe("paginator rendering tests", () => { xdescribe("paginator rendering tests", () => {
const options = new ReactRouterEnzymeContext(); const options = new ReactRouterEnzymeContext();

View File

@@ -1,8 +1,9 @@
// @flow // @flow
import {contextPath} from "./urls"; import {contextPath} from "./urls";
export const NOT_FOUND_ERROR_MESSAGE = "not found"; export const NOT_FOUND_ERROR = new Error("not found");
export const UNAUTHORIZED_ERROR_MESSAGE = "unauthorized"; export const UNAUTHORIZED_ERROR = new Error("unauthorized");
export const CONFLICT_ERROR = new Error("conflict");
const fetchOptions: RequestOptions = { const fetchOptions: RequestOptions = {
credentials: "same-origin", credentials: "same-origin",
@@ -15,28 +16,19 @@ function handleStatusCode(response: Response) {
if (!response.ok) { if (!response.ok) {
switch (response.status) { switch (response.status) {
case 401: case 401:
return throwErrorWithMessage(response, UNAUTHORIZED_ERROR_MESSAGE); throw UNAUTHORIZED_ERROR;
case 404: case 404:
return throwErrorWithMessage(response, NOT_FOUND_ERROR_MESSAGE); throw NOT_FOUND_ERROR;
case 409:
throw CONFLICT_ERROR;
default: default:
return throwErrorWithMessage(response, "server returned status code " + response.status); throw new Error("server returned status code " + response.status);
} }
} }
return response; return response;
} }
function throwErrorWithMessage(response: Response, message: string) {
return response.json().then(
json => {
throw Error(json.message);
},
() => {
throw Error(message);
}
);
}
export function createUrl(url: string) { export function createUrl(url: string) {
if (url.includes("://")) { if (url.includes("://")) {
return url; return url;

View File

@@ -0,0 +1,8 @@
// @flow
export type Person = {
name: string,
mail?: string
};
export const EXTENSION_POINT = "avatar.factory";

View File

@@ -1,26 +1,28 @@
//@flow //@flow
import React from "react"; import React from "react";
import {binder} from "@scm-manager/ui-extensions"; import {binder} from "@scm-manager/ui-extensions";
import type {Changeset} from "@scm-manager/ui-types"; import {Image} from "..";
import {Image} from "@scm-manager/ui-components"; import type { Person } from "./Avatar";
import { EXTENSION_POINT } from "./Avatar";
type Props = { type Props = {
changeset: Changeset person: Person
}; };
class AvatarImage extends React.Component<Props> { class AvatarImage extends React.Component<Props> {
render() { render() {
const { changeset } = this.props; const { person } = this.props;
const avatarFactory = binder.getExtension("changeset.avatar-factory"); const avatarFactory = binder.getExtension(EXTENSION_POINT);
if (avatarFactory) { if (avatarFactory) {
const avatar = avatarFactory(changeset); const avatar = avatarFactory(person);
return ( return (
<Image <Image
className="has-rounded-border" className="has-rounded-border"
src={avatar} src={avatar}
alt={changeset.author.name} alt={person.name}
/> />
); );
} }

View File

@@ -1,6 +1,7 @@
//@flow //@flow
import * as React from "react"; import * as React from "react";
import {binder} from "@scm-manager/ui-extensions"; import {binder} from "@scm-manager/ui-extensions";
import { EXTENSION_POINT } from "./Avatar";
type Props = { type Props = {
children: React.Node children: React.Node
@@ -8,7 +9,7 @@ type Props = {
class AvatarWrapper extends React.Component<Props> { class AvatarWrapper extends React.Component<Props> {
render() { render() {
if (binder.hasExtension("changeset.avatar-factory")) { if (binder.hasExtension(EXTENSION_POINT)) {
return <>{this.props.children}</>; return <>{this.props.children}</>;
} }
return null; return null;

View File

@@ -0,0 +1,4 @@
// @flow
export { default as AvatarWrapper } from "./AvatarWrapper";
export { default as AvatarImage } from "./AvatarImage";

View File

@@ -63,8 +63,9 @@ class ConfigurationBinder {
// route for global configuration, passes the current repository to component // route for global configuration, passes the current repository to component
const RepoRoute = ({ url, repository }) => { const RepoRoute = ({url, repository}) => {
return this.route(url + to, <RepositoryComponent repository={repository}/>); const link = repository._links[linkName].href
return this.route(url + to, <RepositoryComponent repository={repository} link={link}/>);
}; };
// bind config route to extension point // bind config route to extension point

View File

@@ -0,0 +1,88 @@
//@flow
import React from "react";
import type { AutocompleteObject, SelectValue } from "@scm-manager/ui-types";
import Autocomplete from "../Autocomplete";
import AddButton from "../buttons/AddButton";
type Props = {
addEntry: SelectValue => void,
disabled: boolean,
buttonLabel: string,
fieldLabel: string,
helpText?: string,
loadSuggestions: string => Promise<AutocompleteObject>,
placeholder?: string,
loadingMessage?: string,
noOptionsMessage?: string
};
type State = {
selectedValue?: SelectValue
};
class AutocompleteAddEntryToTableField extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { selectedValue: undefined };
}
render() {
const {
disabled,
buttonLabel,
fieldLabel,
helpText,
loadSuggestions,
placeholder,
loadingMessage,
noOptionsMessage
} = this.props;
const { selectedValue } = this.state;
return (
<div className="field">
<Autocomplete
label={fieldLabel}
loadSuggestions={loadSuggestions}
valueSelected={this.handleAddEntryChange}
helpText={helpText}
value={selectedValue}
placeholder={placeholder}
loadingMessage={loadingMessage}
noOptionsMessage={noOptionsMessage}
/>
<AddButton
label={buttonLabel}
action={this.addButtonClicked}
disabled={disabled}
/>
</div>
);
}
addButtonClicked = (event: Event) => {
event.preventDefault();
this.appendEntry();
};
appendEntry = () => {
const { selectedValue } = this.state;
if (!selectedValue) {
return;
}
// $FlowFixMe null is needed to clear the selection; undefined does not work
this.setState({ ...this.state, selectedValue: null }, () =>
this.props.addEntry(selectedValue)
);
};
handleAddEntryChange = (selection: SelectValue) => {
this.setState({
...this.state,
selectedValue: selection
});
};
}
export default AutocompleteAddEntryToTableField;

View File

@@ -1,6 +1,6 @@
//@flow //@flow
import React from "react"; import React from "react";
import Help from '../Help'; import Help from "../Help.js";
type Props = { type Props = {
label?: string, label?: string,

View File

@@ -11,7 +11,7 @@ type State = {
passwordConfirmationFailed: boolean passwordConfirmationFailed: boolean
}; };
type Props = { type Props = {
passwordChanged: string => void, passwordChanged: (string, boolean) => void,
passwordValidator?: string => boolean, passwordValidator?: string => boolean,
// Context props // Context props
t: string => string t: string => string
@@ -98,14 +98,12 @@ class PasswordConfirmation extends React.Component<Props, State> {
); );
}; };
isValid = () => {
return this.state.passwordValid && !this.state.passwordConfirmationFailed
};
propagateChange = () => { propagateChange = () => {
if ( this.props.passwordChanged(this.state.password, this.isValid());
this.state.password &&
this.state.passwordValid &&
!this.state.passwordConfirmationFailed
) {
this.props.passwordChanged(this.state.password);
}
}; };
} }

View File

@@ -1,6 +1,7 @@
// @create-index // @create-index
export { default as AddEntryToTableField } from "./AddEntryToTableField.js"; export { default as AddEntryToTableField } from "./AddEntryToTableField.js";
export { default as AutocompleteAddEntryToTableField } from "./AutocompleteAddEntryToTableField.js";
export { default as Checkbox } from "./Checkbox.js"; export { default as Checkbox } from "./Checkbox.js";
export { default as InputField } from "./InputField.js"; export { default as InputField } from "./InputField.js";
export { default as Select } from "./Select.js"; export { default as Select } from "./Select.js";

View File

@@ -23,12 +23,15 @@ export { default as Help } from "./Help";
export { default as HelpIcon } from "./HelpIcon"; export { default as HelpIcon } from "./HelpIcon";
export { default as Tooltip } from "./Tooltip"; export { default as Tooltip } from "./Tooltip";
export { getPageFromMatch } from "./urls"; export { getPageFromMatch } from "./urls";
export { default as Autocomplete} from "./Autocomplete";
export { apiClient, NOT_FOUND_ERROR_MESSAGE, UNAUTHORIZED_ERROR_MESSAGE } from "./apiclient.js"; export { apiClient, NOT_FOUND_ERROR, UNAUTHORIZED_ERROR, CONFLICT_ERROR } from "./apiclient.js";
export * from "./avatar";
export * from "./buttons"; export * from "./buttons";
export * from "./config"; export * from "./config";
export * from "./forms"; export * from "./forms";
export * from "./layout"; export * from "./layout";
export * from "./modals"; export * from "./modals";
export * from "./navigation"; export * from "./navigation";
export * from "./repos";

View File

@@ -0,0 +1,36 @@
//@flow
import React from "react";
import { Diff2Html } from "diff2html";
type Props = {
diff: string,
sideBySide: boolean
};
class Diff extends React.Component<Props> {
static defaultProps = {
sideBySide: false
};
render() {
const { diff, sideBySide } = this.props;
const options = {
inputFormat: "diff",
outputFormat: sideBySide ? "side-by-side" : "line-by-line",
showFiles: false,
matching: "lines"
};
const outputHtml = Diff2Html.getPrettyHtml(diff, options);
return (
// eslint-disable-next-line react/no-danger
<div dangerouslySetInnerHTML={{ __html: outputHtml }} />
);
}
}
export default Diff;

View File

@@ -0,0 +1,64 @@
//@flow
import React from "react";
import { apiClient } from "../apiclient";
import ErrorNotification from "../ErrorNotification";
import Loading from "../Loading";
import Diff from "./Diff";
type Props = {
url: string,
sideBySide: boolean
};
type State = {
diff?: string,
loading: boolean,
error?: Error
};
class LoadingDiff extends React.Component<Props, State> {
static defaultProps = {
sideBySide: false
};
constructor(props: Props) {
super(props);
this.state = {
loading: true
};
}
componentDidMount() {
const { url } = this.props;
apiClient
.get(url)
.then(response => response.text())
.then(text => {
this.setState({
loading: false,
diff: text
});
})
.catch(error => {
this.setState({
loading: false,
error
});
});
}
render() {
const { diff, loading, error } = this.state;
if (error) {
return <ErrorNotification error={error} />;
} else if (loading || !diff) {
return <Loading />;
} else {
return <Diff diff={diff} />;
}
}
}
export default LoadingDiff;

View File

@@ -1,13 +1,12 @@
//@flow //@flow
import React from "react"; import React from "react";
import type { Changeset } from "@scm-manager/ui-types"; import type {Changeset} from "@scm-manager/ui-types";
type Props = { type Props = {
changeset: Changeset changeset: Changeset
}; };
export default class ChangesetAuthor extends React.Component<Props> { class ChangesetAuthor extends React.Component<Props> {
render() { render() {
const { changeset } = this.props; const { changeset } = this.props;
if (!changeset.author) { if (!changeset.author) {
@@ -35,3 +34,5 @@ export default class ChangesetAuthor extends React.Component<Props> {
} }
} }
} }
export default ChangesetAuthor;

View File

@@ -0,0 +1,37 @@
//@flow
import React from "react";
import type { Changeset } from "@scm-manager/ui-types";
import LoadingDiff from "../LoadingDiff";
import Notification from "../../Notification";
import {translate} from "react-i18next";
type Props = {
changeset: Changeset,
// context props
t: string => string
};
class ChangesetDiff extends React.Component<Props> {
isDiffSupported(changeset: Changeset) {
return !!changeset._links.diff;
}
createUrl(changeset: Changeset) {
return changeset._links.diff.href + "?format=GIT";
}
render() {
const { changeset, t } = this.props;
if (!this.isDiffSupported(changeset)) {
return <Notification type="danger">{t("changesets.diff.not-supported")}</Notification>;
} else {
const url = this.createUrl(changeset);
return <LoadingDiff url={url} />;
}
}
}
export default translate("repos")(ChangesetDiff);

View File

@@ -1,8 +1,8 @@
// @flow // @flow
import ChangesetRow from "./ChangesetRow"; import ChangesetRow from "./ChangesetRow";
import React from "react"; import React from "react";
import type { Changeset, Repository } from "@scm-manager/ui-types"; import type { Changeset, Repository } from "@scm-manager/ui-types";
import classNames from "classnames";
type Props = { type Props = {
repository: Repository, repository: Repository,
@@ -21,7 +21,7 @@ class ChangesetList extends React.Component<Props> {
/> />
); );
}); });
return <div className={classNames("box")}>{content}</div>; return <div className="box">{content}</div>;
} }
} }

View File

@@ -1,17 +1,17 @@
//@flow //@flow
import React from "react"; import React from "react";
import type {Changeset, Repository, Tag} from "@scm-manager/ui-types"; import type { Changeset, Repository, Tag } from "@scm-manager/ui-types";
import classNames from "classnames"; import classNames from "classnames";
import {Interpolate, translate} from "react-i18next"; import {Interpolate, translate} from "react-i18next";
import ChangesetId from "./ChangesetId"; import ChangesetId from "./ChangesetId";
import injectSheet from "react-jss"; import injectSheet from "react-jss";
import {DateFromNow} from "@scm-manager/ui-components"; import {DateFromNow} from "../..";
import ChangesetAuthor from "./ChangesetAuthor"; import ChangesetAuthor from "./ChangesetAuthor";
import ChangesetTag from "./ChangesetTag"; import ChangesetTag from "./ChangesetTag";
import {compose} from "redux";
import {parseDescription} from "./changesets"; import {parseDescription} from "./changesets";
import AvatarWrapper from "./AvatarWrapper"; import {AvatarWrapper, AvatarImage} from "../../avatar";
import AvatarImage from "./AvatarImage";
const styles = { const styles = {
pointer: { pointer: {
@@ -56,7 +56,7 @@ class ChangesetRow extends React.Component<Props> {
<div> <div>
<figure className="media-left"> <figure className="media-left">
<p className="image is-64x64"> <p className="image is-64x64">
<AvatarImage changeset={changeset} /> <AvatarImage person={changeset.author} />
</p> </p>
</figure> </figure>
</div> </div>
@@ -95,7 +95,4 @@ class ChangesetRow extends React.Component<Props> {
}; };
} }
export default compose( export default injectSheet(styles)(translate("repos")(ChangesetRow));
injectSheet(styles),
translate("repos")
)(ChangesetRow);

View File

@@ -0,0 +1,10 @@
// @flow
import * as changesets from "./changesets";
export { changesets };
export { default as ChangesetAuthor } from "./ChangesetAuthor";
export { default as ChangesetId } from "./ChangesetId";
export { default as ChangesetList } from "./ChangesetList";
export { default as ChangesetRow } from "./ChangesetRow";
export { default as ChangesetTag } from "./ChangesetTag";
export { default as ChangesetDiff } from "./ChangesetDiff";

View File

@@ -0,0 +1,5 @@
// @flow
export * from "./changesets";
export { default as Diff } from "./Diff";
export { default as LoadingDiff } from "./LoadingDiff";

View File

@@ -576,6 +576,12 @@
"@babel/plugin-transform-react-jsx-self" "^7.0.0" "@babel/plugin-transform-react-jsx-self" "^7.0.0"
"@babel/plugin-transform-react-jsx-source" "^7.0.0" "@babel/plugin-transform-react-jsx-source" "^7.0.0"
"@babel/runtime@^7.1.2":
version "7.1.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.5.tgz#4170907641cf1f61508f563ece3725150cc6fe39"
dependencies:
regenerator-runtime "^0.12.0"
"@babel/template@^7.1.0", "@babel/template@^7.1.2": "@babel/template@^7.1.0", "@babel/template@^7.1.2":
version "7.1.2" version "7.1.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644"
@@ -606,6 +612,46 @@
lodash "^4.17.10" lodash "^4.17.10"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@emotion/babel-utils@^0.6.4":
version "0.6.10"
resolved "https://registry.yarnpkg.com/@emotion/babel-utils/-/babel-utils-0.6.10.tgz#83dbf3dfa933fae9fc566e54fbb45f14674c6ccc"
dependencies:
"@emotion/hash" "^0.6.6"
"@emotion/memoize" "^0.6.6"
"@emotion/serialize" "^0.9.1"
convert-source-map "^1.5.1"
find-root "^1.1.0"
source-map "^0.7.2"
"@emotion/hash@^0.6.2", "@emotion/hash@^0.6.6":
version "0.6.6"
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.6.tgz#62266c5f0eac6941fece302abad69f2ee7e25e44"
"@emotion/memoize@^0.6.1", "@emotion/memoize@^0.6.6":
version "0.6.6"
resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.6.6.tgz#004b98298d04c7ca3b4f50ca2035d4f60d2eed1b"
"@emotion/serialize@^0.9.1":
version "0.9.1"
resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.9.1.tgz#a494982a6920730dba6303eb018220a2b629c145"
dependencies:
"@emotion/hash" "^0.6.6"
"@emotion/memoize" "^0.6.6"
"@emotion/unitless" "^0.6.7"
"@emotion/utils" "^0.8.2"
"@emotion/stylis@^0.7.0":
version "0.7.1"
resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.7.1.tgz#50f63225e712d99e2b2b39c19c70fff023793ca5"
"@emotion/unitless@^0.6.2", "@emotion/unitless@^0.6.7":
version "0.6.7"
resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.6.7.tgz#53e9f1892f725b194d5e6a1684a7b394df592397"
"@emotion/utils@^0.8.2":
version "0.8.2"
resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.2.tgz#576ff7fb1230185b619a75d258cbc98f0867a8dc"
"@gulp-sourcemaps/identity-map@1.X": "@gulp-sourcemaps/identity-map@1.X":
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.2.tgz#1e6fe5d8027b1f285dc0d31762f566bccd73d5a9" resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.2.tgz#1e6fe5d8027b1f285dc0d31762f566bccd73d5a9"
@@ -641,9 +687,9 @@
version "0.0.2" version "0.0.2"
resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85"
"@scm-manager/ui-bundler@^0.0.21": "@scm-manager/ui-bundler@^0.0.24":
version "0.0.21" version "0.0.24"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e"
dependencies: dependencies:
"@babel/core" "^7.0.0" "@babel/core" "^7.0.0"
"@babel/plugin-proposal-class-properties" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0"
@@ -1121,6 +1167,23 @@ babel-messages@^6.23.0:
dependencies: dependencies:
babel-runtime "^6.22.0" babel-runtime "^6.22.0"
babel-plugin-emotion@^9.2.11:
version "9.2.11"
resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-9.2.11.tgz#319c005a9ee1d15bb447f59fe504c35fd5807728"
dependencies:
"@babel/helper-module-imports" "^7.0.0"
"@emotion/babel-utils" "^0.6.4"
"@emotion/hash" "^0.6.2"
"@emotion/memoize" "^0.6.1"
"@emotion/stylis" "^0.7.0"
babel-plugin-macros "^2.0.0"
babel-plugin-syntax-jsx "^6.18.0"
convert-source-map "^1.5.0"
find-root "^1.1.0"
mkdirp "^0.5.1"
source-map "^0.5.7"
touch "^2.0.1"
babel-plugin-istanbul@^4.1.6: babel-plugin-istanbul@^4.1.6:
version "4.1.6" version "4.1.6"
resolved "http://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45" resolved "http://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45"
@@ -1134,6 +1197,17 @@ babel-plugin-jest-hoist@^23.2.0:
version "23.2.0" version "23.2.0"
resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167" resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-23.2.0.tgz#e61fae05a1ca8801aadee57a6d66b8cefaf44167"
babel-plugin-macros@^2.0.0:
version "2.4.2"
resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.4.2.tgz#21b1a2e82e2130403c5ff785cba6548e9b644b28"
dependencies:
cosmiconfig "^5.0.5"
resolve "^1.8.1"
babel-plugin-syntax-jsx@^6.18.0:
version "6.18.0"
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
babel-plugin-syntax-object-rest-spread@^6.13.0: babel-plugin-syntax-object-rest-spread@^6.13.0:
version "6.13.0" version "6.13.0"
resolved "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" resolved "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5"
@@ -1633,12 +1707,24 @@ cached-path-relative@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.1.tgz#d09c4b52800aa4c078e2dd81a869aac90d2e54e7" resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.1.tgz#d09c4b52800aa4c078e2dd81a869aac90d2e54e7"
caller-callsite@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134"
dependencies:
callsites "^2.0.0"
caller-path@^0.1.0: caller-path@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f"
dependencies: dependencies:
callsites "^0.2.0" callsites "^0.2.0"
caller-path@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4"
dependencies:
caller-callsite "^2.0.0"
callsite@1.0.0: callsite@1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
@@ -1782,7 +1868,7 @@ class-utils@^0.3.5:
isobject "^3.0.0" isobject "^3.0.0"
static-extend "^0.1.1" static-extend "^0.1.1"
classnames@^2.2.6: classnames@^2.2.5, classnames@^2.2.6:
version "2.2.6" version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
@@ -1966,7 +2052,7 @@ contains-path@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a"
convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.1: convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20"
dependencies: dependencies:
@@ -1992,6 +2078,15 @@ core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
cosmiconfig@^5.0.5:
version "5.0.7"
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.7.tgz#39826b292ee0d78eda137dfa3173bd1c21a43b04"
dependencies:
import-fresh "^2.0.0"
is-directory "^0.3.1"
js-yaml "^3.9.0"
parse-json "^4.0.0"
coveralls@^2.11.3: coveralls@^2.11.3:
version "2.13.3" version "2.13.3"
resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-2.13.3.tgz#9ad7c2ae527417f361e8b626483f48ee92dd2bc7" resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-2.13.3.tgz#9ad7c2ae527417f361e8b626483f48ee92dd2bc7"
@@ -2009,6 +2104,18 @@ create-ecdh@^4.0.0:
bn.js "^4.1.0" bn.js "^4.1.0"
elliptic "^6.0.0" elliptic "^6.0.0"
create-emotion@^9.2.12:
version "9.2.12"
resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-9.2.12.tgz#0fc8e7f92c4f8bb924b0fef6781f66b1d07cb26f"
dependencies:
"@emotion/hash" "^0.6.2"
"@emotion/memoize" "^0.6.1"
"@emotion/stylis" "^0.7.0"
"@emotion/unitless" "^0.6.2"
csstype "^2.5.2"
stylis "^3.5.0"
stylis-rule-sheet "^0.0.10"
create-hash@^1.1.0, create-hash@^1.1.2: create-hash@^1.1.0, create-hash@^1.1.2:
version "1.2.0" version "1.2.0"
resolved "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" resolved "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196"
@@ -2122,6 +2229,10 @@ cssstyle@^1.0.0:
dependencies: dependencies:
cssom "0.3.x" cssom "0.3.x"
csstype@^2.5.2:
version "2.5.7"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.5.7.tgz#bf9235d5872141eccfb2d16d82993c6b149179ff"
d@1: d@1:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f"
@@ -2333,7 +2444,16 @@ dev-ip@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/dev-ip/-/dev-ip-1.0.1.tgz#a76a3ed1855be7a012bb8ac16cb80f3c00dc28f0" resolved "https://registry.yarnpkg.com/dev-ip/-/dev-ip-1.0.1.tgz#a76a3ed1855be7a012bb8ac16cb80f3c00dc28f0"
diff@^3.2.0: diff2html@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/diff2html/-/diff2html-2.5.0.tgz#2d16f1a8f115354733b16b0264a594fa7db98aa2"
dependencies:
diff "^3.5.0"
hogan.js "^3.0.2"
lodash "^4.17.11"
whatwg-fetch "^3.0.0"
diff@^3.2.0, diff@^3.5.0:
version "3.5.0" version "3.5.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
@@ -2362,6 +2482,12 @@ doctrine@^2.1.0:
dependencies: dependencies:
esutils "^2.0.2" esutils "^2.0.2"
dom-helpers@^3.3.1:
version "3.4.0"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"
dependencies:
"@babel/runtime" "^7.1.2"
dom-serializer@0, dom-serializer@~0.1.0: dom-serializer@0, dom-serializer@~0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82"
@@ -2466,6 +2592,13 @@ emoji-regex@^6.5.1:
version "6.5.1" version "6.5.1"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2"
emotion@^9.1.2:
version "9.2.12"
resolved "https://registry.yarnpkg.com/emotion/-/emotion-9.2.12.tgz#53925aaa005614e65c6e43db8243c843574d1ea9"
dependencies:
babel-plugin-emotion "^9.2.11"
create-emotion "^9.2.12"
encodeurl@~1.0.1, encodeurl@~1.0.2: encodeurl@~1.0.1, encodeurl@~1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
@@ -2561,7 +2694,7 @@ enzyme@^3.5.0:
rst-selector-parser "^2.2.3" rst-selector-parser "^2.2.3"
string.prototype.trim "^1.1.2" string.prototype.trim "^1.1.2"
error-ex@^1.2.0: error-ex@^1.2.0, error-ex@^1.3.1:
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
dependencies: dependencies:
@@ -3070,6 +3203,10 @@ find-node-modules@^1.0.4:
findup-sync "0.4.2" findup-sync "0.4.2"
merge "^1.2.0" merge "^1.2.0"
find-root@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4"
find-up@^1.0.0: find-up@^1.0.0:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
@@ -3730,6 +3867,13 @@ hoek@2.x.x:
version "2.16.3" version "2.16.3"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed"
hogan.js@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/hogan.js/-/hogan.js-3.0.2.tgz#4cd9e1abd4294146e7679e41d7898732b02c7bfd"
dependencies:
mkdirp "0.3.0"
nopt "1.0.10"
hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0: hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0:
version "2.5.5" version "2.5.5"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47"
@@ -3862,6 +4006,13 @@ immutable@^3:
version "3.8.2" version "3.8.2"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
import-fresh@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546"
dependencies:
caller-path "^2.0.0"
resolve-from "^3.0.0"
import-local@^1.0.0: import-local@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc" resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc"
@@ -4038,6 +4189,10 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2:
is-data-descriptor "^1.0.0" is-data-descriptor "^1.0.0"
kind-of "^6.0.2" kind-of "^6.0.2"
is-directory@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1"
is-dotfile@^1.0.0: is-dotfile@^1.0.0:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
@@ -4689,7 +4844,7 @@ js-yaml@3.6.1:
argparse "^1.0.7" argparse "^1.0.7"
esprima "^2.6.0" esprima "^2.6.0"
js-yaml@^3.12.0, js-yaml@^3.7.0: js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0:
version "3.12.0" version "3.12.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1"
dependencies: dependencies:
@@ -4743,6 +4898,10 @@ jsesc@~0.5.0:
version "0.5.0" version "0.5.0"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
json-parse-better-errors@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
json-schema-traverse@^0.3.0: json-schema-traverse@^0.3.0:
version "0.3.1" version "0.3.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
@@ -5115,7 +5274,7 @@ lodash.templatesettings@^3.0.0:
lodash._reinterpolate "^3.0.0" lodash._reinterpolate "^3.0.0"
lodash.escape "^3.0.0" lodash.escape "^3.0.0"
lodash@^4.13.1, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5: lodash@^4.13.1, lodash@^4.15.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5:
version "4.17.11" version "4.17.11"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
@@ -5127,7 +5286,7 @@ log-driver@1.2.5:
version "1.2.5" version "1.2.5"
resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.5.tgz#7ae4ec257302fd790d557cb10c97100d857b0056" resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.5.tgz#7ae4ec257302fd790d557cb10c97100d857b0056"
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
dependencies: dependencies:
@@ -5216,6 +5375,10 @@ mem@^1.1.0:
dependencies: dependencies:
mimic-fn "^1.0.0" mimic-fn "^1.0.0"
memoize-one@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.3.tgz#cdfdd942853f1a1b4c71c5336b8c49da0bf0273c"
memoizee@0.4.X: memoizee@0.4.X:
version "0.4.14" version "0.4.14"
resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57"
@@ -5371,6 +5534,10 @@ mixin-deep@^1.2.0:
for-in "^1.0.2" for-in "^1.0.2"
is-extendable "^1.0.1" is-extendable "^1.0.1"
mkdirp@0.3.0:
version "0.3.0"
resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e"
"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1: "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1:
version "0.5.1" version "0.5.1"
resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
@@ -5549,6 +5716,12 @@ nomnom@~1.6.2:
colors "0.5.x" colors "0.5.x"
underscore "~1.4.4" underscore "~1.4.4"
nopt@1.0.10, nopt@~1.0.10:
version "1.0.10"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee"
dependencies:
abbrev "1"
nopt@^4.0.1: nopt@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
@@ -5907,6 +6080,13 @@ parse-json@^2.2.0:
dependencies: dependencies:
error-ex "^1.2.0" error-ex "^1.2.0"
parse-json@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
dependencies:
error-ex "^1.3.1"
json-parse-better-errors "^1.0.1"
parse-passwd@^1.0.0: parse-passwd@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
@@ -6279,6 +6459,12 @@ react-i18next@^7.11.0:
html-parse-stringify2 "2.0.1" html-parse-stringify2 "2.0.1"
prop-types "^15.6.0" prop-types "^15.6.0"
react-input-autosize@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8"
dependencies:
prop-types "^15.5.8"
react-is@^16.5.2: react-is@^16.5.2:
version "16.5.2" version "16.5.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.2.tgz#e2a7b7c3f5d48062eb769fcb123505eb928722e3" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.2.tgz#e2a7b7c3f5d48062eb769fcb123505eb928722e3"
@@ -6293,6 +6479,10 @@ react-jss@^8.6.1:
prop-types "^15.6.0" prop-types "^15.6.0"
theming "^1.3.0" theming "^1.3.0"
react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
react-router-dom@^4.3.1: react-router-dom@^4.3.1:
version "4.3.1" version "4.3.1"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6"
@@ -6323,6 +6513,18 @@ react-router@^4.3.1:
prop-types "^15.6.1" prop-types "^15.6.1"
warning "^4.0.1" warning "^4.0.1"
react-select@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.1.2.tgz#7a3e4c2b9efcd8c44ae7cf6ebb8b060ef69c513c"
dependencies:
classnames "^2.2.5"
emotion "^9.1.2"
memoize-one "^4.0.0"
prop-types "^15.6.0"
raf "^3.4.0"
react-input-autosize "^2.2.1"
react-transition-group "^2.2.1"
react-test-renderer@^16.0.0-0: react-test-renderer@^16.0.0-0:
version "16.5.2" version "16.5.2"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.5.2.tgz#92e9d2c6f763b9821b2e0b22f994ee675068b5ae" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.5.2.tgz#92e9d2c6f763b9821b2e0b22f994ee675068b5ae"
@@ -6332,6 +6534,15 @@ react-test-renderer@^16.0.0-0:
react-is "^16.5.2" react-is "^16.5.2"
schedule "^0.5.0" schedule "^0.5.0"
react-transition-group@^2.2.1:
version "2.5.0"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.5.0.tgz#70bca0e3546102c4dc5cf3f5f57f73447cce6874"
dependencies:
dom-helpers "^3.3.1"
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-lifecycles-compat "^3.0.4"
react@^16.4.2: react@^16.4.2:
version "16.6.0" version "16.6.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.6.0.tgz#b34761cfaf3e30f5508bc732fb4736730b7da246" resolved "https://registry.yarnpkg.com/react/-/react-16.6.0.tgz#b34761cfaf3e30f5508bc732fb4736730b7da246"
@@ -6466,6 +6677,10 @@ regenerator-runtime@^0.11.0:
version "0.11.1" version "0.11.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
regenerator-runtime@^0.12.0:
version "0.12.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
regenerator-transform@^0.13.3: regenerator-transform@^0.13.3:
version "0.13.3" version "0.13.3"
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb"
@@ -6659,7 +6874,7 @@ resolve@1.1.7:
version "1.1.7" version "1.1.7"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0: resolve@^1.1.4, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0, resolve@^1.8.1:
version "1.8.1" version "1.8.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26"
dependencies: dependencies:
@@ -7033,6 +7248,10 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
version "0.6.1" version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
source-map@^0.7.2:
version "0.7.3"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
sparkles@^1.0.0: sparkles@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.1.tgz#008db65edce6c50eec0c5e228e1945061dd0437c" resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.1.tgz#008db65edce6c50eec0c5e228e1945061dd0437c"
@@ -7243,6 +7462,14 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
stylis-rule-sheet@^0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430"
stylis@^3.5.0:
version "3.5.4"
resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe"
subarg@^1.0.0: subarg@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2"
@@ -7442,6 +7669,12 @@ to-regex@^3.0.1, to-regex@^3.0.2:
regex-not "^1.0.2" regex-not "^1.0.2"
safe-regex "^1.1.0" safe-regex "^1.1.0"
touch@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164"
dependencies:
nopt "~1.0.10"
tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3: tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@~2.4.3:
version "2.4.3" version "2.4.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
@@ -7827,6 +8060,10 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3:
dependencies: dependencies:
iconv-lite "0.4.24" iconv-lite "0.4.24"
whatwg-fetch@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"
whatwg-mimetype@^2.1.0: whatwg-mimetype@^2.1.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.2.0.tgz#a3d58ef10b76009b042d03e25591ece89b88d171" resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.2.0.tgz#a3d58ef10b76009b042d03e25591ece89b88d171"

View File

@@ -14,7 +14,7 @@
"check": "flow check" "check": "flow check"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.21" "@scm-manager/ui-bundler": "^0.0.24"
}, },
"browserify": { "browserify": {
"transform": [ "transform": [
@@ -33,4 +33,4 @@
] ]
] ]
} }
} }

View File

@@ -0,0 +1,10 @@
// @flow
export type AutocompleteObject = {
id: string,
displayName: string
};
export type SelectValue = {
value: AutocompleteObject,
label: string
};

View File

@@ -22,3 +22,5 @@ export type { IndexResources } from "./IndexResources";
export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions"; export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions";
export type { SubRepository, File } from "./Sources"; export type { SubRepository, File } from "./Sources";
export type { SelectValue, AutocompleteObject } from "./Autocomplete";

View File

@@ -707,9 +707,9 @@
version "0.0.2" version "0.0.2"
resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85"
"@scm-manager/ui-bundler@^0.0.21": "@scm-manager/ui-bundler@^0.0.24":
version "0.0.21" version "0.0.24"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.24.tgz#034d5500c79b438c48d8f7ee985be07c4ea46d1e"
dependencies: dependencies:
"@babel/core" "^7.0.0" "@babel/core" "^7.0.0"
"@babel/plugin-proposal-class-properties" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0"

View File

@@ -11,7 +11,7 @@
"bulma": "^0.7.1", "bulma": "^0.7.1",
"bulma-tooltip": "^2.0.2", "bulma-tooltip": "^2.0.2",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"diff2html": "^2.4.0", "diff2html": "^2.5.0",
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",
"history": "^4.7.2", "history": "^4.7.2",
"i18next": "^11.4.0", "i18next": "^11.4.0",
@@ -21,13 +21,13 @@
"node-sass": "^4.9.3", "node-sass": "^4.9.3",
"postcss-easy-import": "^3.0.0", "postcss-easy-import": "^3.0.0",
"react": "^16.4.2", "react": "^16.4.2",
"react-diff-view": "^1.7.0",
"react-dom": "^16.4.2", "react-dom": "^16.4.2",
"react-i18next": "^7.9.0", "react-i18next": "^7.9.0",
"react-jss": "^8.6.0", "react-jss": "^8.6.0",
"react-redux": "^5.0.7", "react-redux": "^5.0.7",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",
"react-router-redux": "^5.0.0-alpha.9", "react-router-redux": "^5.0.0-alpha.9",
"react-select": "^2.1.2",
"react-syntax-highlighter": "^9.0.1", "react-syntax-highlighter": "^9.0.1",
"redux": "^4.0.0", "redux": "^4.0.0",
"redux-devtools-extension": "^2.13.5", "redux-devtools-extension": "^2.13.5",
@@ -51,7 +51,7 @@
"pre-commit": "jest && flow && eslint src" "pre-commit": "jest && flow && eslint src"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.21", "@scm-manager/ui-bundler": "^0.0.24",
"concat": "^1.0.3", "concat": "^1.0.3",
"copyfiles": "^2.0.0", "copyfiles": "^2.0.0",
"enzyme": "^3.3.0", "enzyme": "^3.3.0",

View File

@@ -20,7 +20,9 @@
} }
}, },
"error-notification": { "error-notification": {
"prefix": "Error" "prefix": "Error",
"loginLink": "You can login here again.",
"timeout": "The session has expired."
}, },
"loading": { "loading": {
"alt": "Loading ..." "alt": "Loading ..."

View File

@@ -39,7 +39,13 @@
"label": "Add member", "label": "Add member",
"error": "Invalid member name" "error": "Invalid member name"
}, },
"group-form": { "add-member-autocomplete": {
"placeholder": "Enter member",
"loading": "Loading...",
"no-options": "No suggestion available"
},
"group-form": {
"submit": "Submit", "submit": "Submit",
"name-error": "Group name is invalid", "name-error": "Group name is invalid",
"description-error": "Description is invalid", "description-error": "Description is invalid",

View File

@@ -66,6 +66,9 @@
} }
}, },
"changesets": { "changesets": {
"diff": {
"not-supported": "Diff of changesets is not supported by the type of repository"
},
"error-title": "Error", "error-title": "Error",
"error-subtitle": "Could not fetch changesets", "error-subtitle": "Could not fetch changesets",
"changeset": { "changeset": {
@@ -84,11 +87,14 @@
"label": "Branches" "label": "Branches"
}, },
"permission": { "permission": {
"user": "User",
"group": "Group",
"error-title": "Error", "error-title": "Error",
"error-subtitle": "Unknown permissions error", "error-subtitle": "Unknown permissions error",
"name": "User or Group", "name": "User or Group",
"type": "Type", "type": "Type",
"group-permission": "Group Permission", "group-permission": "Group Permission",
"user-permission": "User Permission",
"edit-permission": { "edit-permission": {
"delete-button": "Delete", "delete-button": "Delete",
"save-button": "Save Changes" "save-button": "Save Changes"
@@ -111,6 +117,13 @@
"groupPermissionHelpText": "States if a permission is a group permission.", "groupPermissionHelpText": "States if a permission is a group permission.",
"nameHelpText": "Manage permissions for a specific user or group", "nameHelpText": "Manage permissions for a specific user or group",
"typeHelpText": "READ = read; WRITE = read and write; OWNER = read, write and also the ability to manage the properties and permissions" "typeHelpText": "READ = read; WRITE = read and write; OWNER = read, write and also the ability to manage the properties and permissions"
},
"autocomplete": {
"no-group-options": "No group suggestion available",
"group-placeholder": "Enter group",
"no-user-options": "No user suggestion available",
"user-placeholder": "Enter user",
"loading": "Loading..."
} }
}, },
"help": { "help": {

View File

@@ -32,9 +32,8 @@ export function fetchConfig(link: string) {
.then(data => { .then(data => {
dispatch(fetchConfigSuccess(data)); dispatch(fetchConfigSuccess(data));
}) })
.catch(cause => { .catch(err => {
const error = new Error(`could not fetch config: ${cause.message}`); dispatch(fetchConfigFailure(err));
dispatch(fetchConfigFailure(error));
}); });
}; };
} }
@@ -73,13 +72,8 @@ export function modifyConfig(config: Config, callback?: () => void) {
callback(); callback();
} }
}) })
.catch(cause => { .catch(err => {
dispatch( dispatch(modifyConfigFailure(config, err));
modifyConfigFailure(
config,
new Error(`could not modify config: ${cause.message}`)
)
);
}); });
}; };
} }

View File

@@ -21,7 +21,8 @@ type State = {
password: string, password: string,
loading: boolean, loading: boolean,
error?: Error, error?: Error,
passwordChanged: boolean passwordChanged: boolean,
passwordValid: boolean
}; };
class ChangeUserPassword extends React.Component<Props, State> { class ChangeUserPassword extends React.Component<Props, State> {
@@ -35,7 +36,8 @@ class ChangeUserPassword extends React.Component<Props, State> {
passwordConfirmationError: false, passwordConfirmationError: false,
validatePasswordError: false, validatePasswordError: false,
validatePassword: "", validatePassword: "",
passwordChanged: false passwordChanged: false,
passwordValid: false
}; };
} }
@@ -83,6 +85,10 @@ class ChangeUserPassword extends React.Component<Props, State> {
} }
}; };
isValid = () => {
return this.state.oldPassword && this.state.passwordValid;
};
render() { render() {
const { t } = this.props; const { t } = this.props;
const { loading, passwordChanged, error } = this.state; const { loading, passwordChanged, error } = this.state;
@@ -118,7 +124,7 @@ class ChangeUserPassword extends React.Component<Props, State> {
key={this.state.passwordChanged ? "changed" : "unchanged"} key={this.state.passwordChanged ? "changed" : "unchanged"}
/> />
<SubmitButton <SubmitButton
disabled={!this.state.password} disabled={!this.isValid()}
loading={loading} loading={loading}
label={t("password.submit")} label={t("password.submit")}
/> />
@@ -126,8 +132,8 @@ class ChangeUserPassword extends React.Component<Props, State> {
); );
} }
passwordChanged = (password: string) => { passwordChanged = (password: string, passwordValid: boolean) => {
this.setState({ ...this.state, password }); this.setState({ ...this.state, password, passwordValid: (!!password && passwordValid) });
}; };
onClose = () => { onClose = () => {

View File

@@ -1,8 +1,7 @@
// @flow // @flow
import React from "react"; import React from "react";
import AvatarWrapper from "../repos/components/changesets/AvatarWrapper";
import type { Me } from "@scm-manager/ui-types"; import type { Me } from "@scm-manager/ui-types";
import { MailLink } from "@scm-manager/ui-components"; import { MailLink, AvatarWrapper, AvatarImage } from "@scm-manager/ui-components";
import { compose } from "redux"; import { compose } from "redux";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
@@ -18,37 +17,35 @@ class ProfileInfo extends React.Component<Props, State> {
render() { render() {
const { me, t } = this.props; const { me, t } = this.props;
return ( return (
<> <div className="media">
<AvatarWrapper> <AvatarWrapper>
<div> <figure className="media-left">
<figure className="media-left"> <p className="image is-64x64">
<p className="image is-64x64"> <AvatarImage person={ me }/>
{ </p>
// TODO: add avatar </figure>
}
</p>
</figure>
</div>
</AvatarWrapper> </AvatarWrapper>
<table className="table"> <div className="media-content">
<tbody> <table className="table">
<tr> <tbody>
<td className="has-text-weight-semibold">{t("profile.username")}</td> <tr>
<td>{me.name}</td> <td className="has-text-weight-semibold">{t("profile.username")}</td>
</tr> <td>{me.name}</td>
<tr> </tr>
<td className="has-text-weight-semibold">{t("profile.displayName")}</td> <tr>
<td>{me.displayName}</td> <td className="has-text-weight-semibold">{t("profile.displayName")}</td>
</tr> <td>{me.displayName}</td>
<tr> </tr>
<td className="has-text-weight-semibold">{t("profile.mail")}</td> <tr>
<td> <td className="has-text-weight-semibold">{t("profile.mail")}</td>
<MailLink address={me.mail} /> <td>
</td> <MailLink address={me.mail} />
</tr> </td>
</tbody> </tr>
</table> </tbody>
</> </table>
</div>
</div>
); );
} }
} }

View File

@@ -2,12 +2,12 @@
import React from "react"; import React from "react";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import { import {
AutocompleteAddEntryToTableField,
InputField, InputField,
SubmitButton, SubmitButton,
Textarea, Textarea
AddEntryToTableField
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import type { Group } from "@scm-manager/ui-types"; import type { Group, SelectValue } from "@scm-manager/ui-types";
import * as validator from "./groupValidation"; import * as validator from "./groupValidation";
import MemberNameTable from "./MemberNameTable"; import MemberNameTable from "./MemberNameTable";
@@ -16,7 +16,8 @@ type Props = {
t: string => string, t: string => string,
submitForm: Group => void, submitForm: Group => void,
loading?: boolean, loading?: boolean,
group?: Group group?: Group,
loadUserSuggestions: string => any
}; };
type State = { type State = {
@@ -70,7 +71,7 @@ class GroupForm extends React.Component<Props, State> {
render() { render() {
const { t, loading } = this.props; const { t, loading } = this.props;
const group = this.state.group; const { group } = this.state;
let nameField = null; let nameField = null;
if (!this.props.group) { if (!this.props.group) {
nameField = ( nameField = (
@@ -97,15 +98,20 @@ class GroupForm extends React.Component<Props, State> {
helpText={t("group-form.help.descriptionHelpText")} helpText={t("group-form.help.descriptionHelpText")}
/> />
<MemberNameTable <MemberNameTable
members={this.state.group.members} members={group.members}
memberListChanged={this.memberListChanged} memberListChanged={this.memberListChanged}
/> />
<AddEntryToTableField
<AutocompleteAddEntryToTableField
addEntry={this.addMember} addEntry={this.addMember}
disabled={false} disabled={false}
buttonLabel={t("add-member-button.label")} buttonLabel={t("add-member-button.label")}
fieldLabel={t("add-member-textfield.label")} fieldLabel={t("add-member-textfield.label")}
errorMessage={t("add-member-textfield.error")} errorMessage={t("add-member-textfield.error")}
loadSuggestions={this.props.loadUserSuggestions}
placeholder={t("add-member-autocomplete.placeholder")}
loadingMessage={t("add-member-autocomplete.loading")}
noOptionsMessage={t("add-member-autocomplete.no-options")}
/> />
<SubmitButton <SubmitButton
disabled={!this.isValid()} disabled={!this.isValid()}
@@ -126,8 +132,8 @@ class GroupForm extends React.Component<Props, State> {
}); });
}; };
addMember = (membername: string) => { addMember = (value: SelectValue) => {
if (this.isMember(membername)) { if (this.isMember(value.value.id)) {
return; return;
} }
@@ -135,7 +141,7 @@ class GroupForm extends React.Component<Props, State> {
...this.state, ...this.state,
group: { group: {
...this.state.group, ...this.state.group,
members: [...this.state.group.members, membername] members: [...this.state.group.members, value.value.id]
} }
}); });
}; };

View File

@@ -13,7 +13,10 @@ import {
} from "../modules/groups"; } from "../modules/groups";
import type { Group } from "@scm-manager/ui-types"; import type { Group } from "@scm-manager/ui-types";
import type { History } from "history"; import type { History } from "history";
import { getGroupsLink } from "../../modules/indexResource"; import {
getGroupsLink,
getUserAutoCompleteLink
} from "../../modules/indexResource";
type Props = { type Props = {
t: string => string, t: string => string,
@@ -22,7 +25,8 @@ type Props = {
loading?: boolean, loading?: boolean,
error?: Error, error?: Error,
resetForm: () => void, resetForm: () => void,
createLink: string createLink: string,
autocompleteLink: string
}; };
type State = {}; type State = {};
@@ -31,6 +35,7 @@ class AddGroup extends React.Component<Props, State> {
componentDidMount() { componentDidMount() {
this.props.resetForm(); this.props.resetForm();
} }
render() { render() {
const { t, loading, error } = this.props; const { t, loading, error } = this.props;
return ( return (
@@ -43,12 +48,26 @@ class AddGroup extends React.Component<Props, State> {
<GroupForm <GroupForm
submitForm={group => this.createGroup(group)} submitForm={group => this.createGroup(group)}
loading={loading} loading={loading}
loadUserSuggestions={this.loadUserAutocompletion}
/> />
</div> </div>
</Page> </Page>
); );
} }
loadUserAutocompletion = (inputValue: string) => {
const url = this.props.autocompleteLink + "?q=";
return fetch(url + inputValue)
.then(response => response.json())
.then(json => {
return json.map(element => {
return {
value: element,
label: `${element.displayName} (${element.id})`
};
});
});
};
groupCreated = () => { groupCreated = () => {
this.props.history.push("/groups"); this.props.history.push("/groups");
}; };
@@ -71,10 +90,12 @@ const mapStateToProps = state => {
const loading = isCreateGroupPending(state); const loading = isCreateGroupPending(state);
const error = getCreateGroupFailure(state); const error = getCreateGroupFailure(state);
const createLink = getGroupsLink(state); const createLink = getGroupsLink(state);
const autocompleteLink = getUserAutoCompleteLink(state);
return { return {
createLink, createLink,
loading, loading,
error error,
autocompleteLink
}; };
}; };

View File

@@ -3,21 +3,23 @@ import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import GroupForm from "../components/GroupForm"; import GroupForm from "../components/GroupForm";
import { import {
modifyGroup, getModifyGroupFailure,
modifyGroupReset,
isModifyGroupPending, isModifyGroupPending,
getModifyGroupFailure modifyGroup,
modifyGroupReset
} from "../modules/groups"; } from "../modules/groups";
import type { History } from "history"; import type { History } from "history";
import { withRouter } from "react-router-dom"; import { withRouter } from "react-router-dom";
import type { Group } from "@scm-manager/ui-types"; import type { Group } from "@scm-manager/ui-types";
import { ErrorNotification } from "@scm-manager/ui-components"; import { ErrorNotification } from "@scm-manager/ui-components";
import { getUserAutoCompleteLink } from "../../modules/indexResource";
type Props = { type Props = {
group: Group, group: Group,
modifyGroup: (group: Group, callback?: () => void) => void, modifyGroup: (group: Group, callback?: () => void) => void,
modifyGroupReset: Group => void, modifyGroupReset: Group => void,
fetchGroup: (name: string) => void, fetchGroup: (name: string) => void,
autocompleteLink: string,
history: History, history: History,
loading?: boolean, loading?: boolean,
error: Error error: Error
@@ -37,6 +39,20 @@ class EditGroup extends React.Component<Props> {
this.props.modifyGroup(group, this.groupModified(group)); this.props.modifyGroup(group, this.groupModified(group));
}; };
loadUserAutocompletion = (inputValue: string) => {
const url = this.props.autocompleteLink + "?q=";
return fetch(url + inputValue)
.then(response => response.json())
.then(json => {
return json.map(element => {
return {
value: element,
label: `${element.displayName} (${element.id})`
};
});
});
};
render() { render() {
const { group, loading, error } = this.props; const { group, loading, error } = this.props;
return ( return (
@@ -48,6 +64,7 @@ class EditGroup extends React.Component<Props> {
this.modifyGroup(group); this.modifyGroup(group);
}} }}
loading={loading} loading={loading}
loadUserSuggestions={this.loadUserAutocompletion}
/> />
</div> </div>
); );
@@ -57,9 +74,11 @@ class EditGroup extends React.Component<Props> {
const mapStateToProps = (state, ownProps) => { const mapStateToProps = (state, ownProps) => {
const loading = isModifyGroupPending(state, ownProps.group.name); const loading = isModifyGroupPending(state, ownProps.group.name);
const error = getModifyGroupFailure(state, ownProps.group.name); const error = getModifyGroupFailure(state, ownProps.group.name);
const autocompleteLink = getUserAutoCompleteLink(state);
return { return {
loading, loading,
error error,
autocompleteLink
}; };
}; };

View File

@@ -54,9 +54,8 @@ export function fetchGroupsByLink(link: string) {
.then(data => { .then(data => {
dispatch(fetchGroupsSuccess(data)); dispatch(fetchGroupsSuccess(data));
}) })
.catch(cause => { .catch(err => {
const error = new Error(`could not fetch groups: ${cause.message}`); dispatch(fetchGroupsFailure(link, err));
dispatch(fetchGroupsFailure(link, error));
}); });
}; };
} }
@@ -105,9 +104,8 @@ function fetchGroup(link: string, name: string) {
.then(data => { .then(data => {
dispatch(fetchGroupSuccess(data)); dispatch(fetchGroupSuccess(data));
}) })
.catch(cause => { .catch(err => {
const error = new Error(`could not fetch group: ${cause.message}`); dispatch(fetchGroupFailure(name, err));
dispatch(fetchGroupFailure(name, error));
}); });
}; };
} }
@@ -151,10 +149,10 @@ export function createGroup(link: string, group: Group, callback?: () => void) {
callback(); callback();
} }
}) })
.catch(error => { .catch(err => {
dispatch( dispatch(
createGroupFailure( createGroupFailure(
new Error(`Failed to create group ${group.name}: ${error.message}`) err
) )
); );
}); });
@@ -201,11 +199,11 @@ export function modifyGroup(group: Group, callback?: () => void) {
.then(() => { .then(() => {
dispatch(fetchGroupByLink(group)); dispatch(fetchGroupByLink(group));
}) })
.catch(cause => { .catch(err => {
dispatch( dispatch(
modifyGroupFailure( modifyGroupFailure(
group, group,
new Error(`could not modify group ${group.name}: ${cause.message}`) err
) )
); );
}); });
@@ -259,11 +257,8 @@ export function deleteGroup(group: Group, callback?: () => void) {
callback(); callback();
} }
}) })
.catch(cause => { .catch(err => {
const error = new Error( dispatch(deleteGroupFailure(group, err));
`could not delete group ${group.name}: ${cause.message}`
);
dispatch(deleteGroupFailure(group, error));
}); });
}; };
} }

View File

@@ -2,10 +2,7 @@
import type { Me } from "@scm-manager/ui-types"; import type { Me } from "@scm-manager/ui-types";
import * as types from "./types"; import * as types from "./types";
import { import { apiClient, UNAUTHORIZED_ERROR } from "@scm-manager/ui-components";
apiClient,
UNAUTHORIZED_ERROR_MESSAGE
} from "@scm-manager/ui-components";
import { isPending } from "./pending"; import { isPending } from "./pending";
import { getFailure } from "./failure"; import { getFailure } from "./failure";
import { import {
@@ -190,7 +187,7 @@ export const fetchMe = (link: string) => {
dispatch(fetchMeSuccess(me)); dispatch(fetchMeSuccess(me));
}) })
.catch((error: Error) => { .catch((error: Error) => {
if (error.message === UNAUTHORIZED_ERROR_MESSAGE) { if (error === UNAUTHORIZED_ERROR) {
dispatch(fetchMeUnauthenticated()); dispatch(fetchMeUnauthenticated());
} else { } else {
dispatch(fetchMeFailure(error)); dispatch(fetchMeFailure(error));

View File

@@ -2,7 +2,7 @@
import * as types from "./types"; import * as types from "./types";
import { apiClient } from "@scm-manager/ui-components"; import { apiClient } from "@scm-manager/ui-components";
import type { Action, IndexResources } from "@scm-manager/ui-types"; import type { Action, IndexResources, Link } from "@scm-manager/ui-types";
import { isPending } from "./pending"; import { isPending } from "./pending";
import { getFailure } from "./failure"; import { getFailure } from "./failure";
@@ -100,6 +100,13 @@ export function getLink(state: Object, name: string) {
} }
} }
export function getLinkCollection(state: Object, name: string): Link[] {
if (state.indexResources.links && state.indexResources.links[name]) {
return state.indexResources.links[name];
}
return [];
}
export function getUiPluginsLink(state: Object) { export function getUiPluginsLink(state: Object) {
return getLink(state, "uiPlugins"); return getLink(state, "uiPlugins");
} }
@@ -143,3 +150,23 @@ export function getGitConfigLink(state: Object) {
export function getSvnConfigLink(state: Object) { export function getSvnConfigLink(state: Object) {
return getLink(state, "svnConfig"); return getLink(state, "svnConfig");
} }
export function getUserAutoCompleteLink(state: Object): string {
const link = getLinkCollection(state, "autocomplete").find(
i => i.name === "users"
);
if (link) {
return link.href;
}
return "";
}
export function getGroupAutoCompleteLink(state: Object): string {
const link = getLinkCollection(state, "autocomplete").find(
i => i.name === "groups"
);
if (link) {
return link.href;
}
return "";
}

View File

@@ -20,7 +20,11 @@ import reducer, {
getHgConfigLink, getHgConfigLink,
getGitConfigLink, getGitConfigLink,
getSvnConfigLink, getSvnConfigLink,
getLinks, getGroupsLink getLinks,
getGroupsLink,
getLinkCollection,
getUserAutoCompleteLink,
getGroupAutoCompleteLink
} from "./indexResource"; } from "./indexResource";
const indexResourcesUnauthenticated = { const indexResourcesUnauthenticated = {
@@ -73,354 +77,404 @@ const indexResourcesAuthenticated = {
}, },
svnConfig: { svnConfig: {
href: "http://localhost:8081/scm/api/v2/config/svn" href: "http://localhost:8081/scm/api/v2/config/svn"
} },
autocomplete: [
{
href: "http://localhost:8081/scm/api/v2/autocomplete/users",
name: "users"
},
{
href: "http://localhost:8081/scm/api/v2/autocomplete/groups",
name: "groups"
}
]
} }
}; };
describe("fetch index resource", () => { describe("index resource", () => {
const index_url = "/api/v2/"; describe("fetch index resource", () => {
const mockStore = configureMockStore([thunk]); const index_url = "/api/v2/";
const mockStore = configureMockStore([thunk]);
afterEach(() => { afterEach(() => {
fetchMock.reset(); fetchMock.reset();
fetchMock.restore(); fetchMock.restore();
}); });
it("should successfully fetch index resources when unauthenticated", () => { it("should successfully fetch index resources when unauthenticated", () => {
fetchMock.getOnce(index_url, indexResourcesUnauthenticated); fetchMock.getOnce(index_url, indexResourcesUnauthenticated);
const expectedActions = [ const expectedActions = [
{ type: FETCH_INDEXRESOURCES_PENDING }, { type: FETCH_INDEXRESOURCES_PENDING },
{ {
type: FETCH_INDEXRESOURCES_SUCCESS, type: FETCH_INDEXRESOURCES_SUCCESS,
payload: indexResourcesUnauthenticated payload: indexResourcesUnauthenticated
} }
]; ];
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchIndexResources()).then(() => { return store.dispatch(fetchIndexResources()).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
});
});
it("should successfully fetch index resources when authenticated", () => {
fetchMock.getOnce(index_url, indexResourcesAuthenticated);
const expectedActions = [
{ type: FETCH_INDEXRESOURCES_PENDING },
{
type: FETCH_INDEXRESOURCES_SUCCESS,
payload: indexResourcesAuthenticated
}
];
const store = mockStore({});
return store.dispatch(fetchIndexResources()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => {
fetchMock.getOnce(index_url, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchIndexResources()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING);
expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE);
expect(actions[1].payload).toBeDefined();
});
}); });
}); });
it("should successfully fetch index resources when authenticated", () => { describe("index resources reducer", () => {
fetchMock.getOnce(index_url, indexResourcesAuthenticated); it("should return empty object, if state and action is undefined", () => {
expect(reducer()).toEqual({});
});
const expectedActions = [ it("should return the same state, if the action is undefined", () => {
{ type: FETCH_INDEXRESOURCES_PENDING }, const state = { x: true };
{ expect(reducer(state)).toBe(state);
type: FETCH_INDEXRESOURCES_SUCCESS, });
payload: indexResourcesAuthenticated
}
];
const store = mockStore({}); it("should return the same state, if the action is unknown to the reducer", () => {
return store.dispatch(fetchIndexResources()).then(() => { const state = { x: true };
expect(store.getActions()).toEqual(expectedActions); expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state);
});
it("should store the index resources on FETCH_INDEXRESOURCES_SUCCESS", () => {
const newState = reducer(
{},
fetchIndexResourcesSuccess(indexResourcesAuthenticated)
);
expect(newState.links).toBe(indexResourcesAuthenticated._links);
}); });
}); });
it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => { describe("index resources selectors", () => {
fetchMock.getOnce(index_url, { const error = new Error("something goes wrong");
status: 500
it("should return true, when fetch index resources is pending", () => {
const state = {
pending: {
[FETCH_INDEXRESOURCES]: true
}
};
expect(isFetchIndexResourcesPending(state)).toEqual(true);
}); });
const store = mockStore({}); it("should return false, when fetch index resources is not pending", () => {
return store.dispatch(fetchIndexResources()).then(() => { expect(isFetchIndexResourcesPending({})).toEqual(false);
const actions = store.getActions(); });
expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING);
expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE); it("should return error when fetch index resources did fail", () => {
expect(actions[1].payload).toBeDefined(); const state = {
failure: {
[FETCH_INDEXRESOURCES]: error
}
};
expect(getFetchIndexResourcesFailure(state)).toEqual(error);
});
it("should return undefined when fetch index resources did not fail", () => {
expect(getFetchIndexResourcesFailure({})).toBe(undefined);
});
it("should return all links", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getLinks(state)).toBe(indexResourcesAuthenticated._links);
});
// ui plugins link
it("should return ui plugins link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getUiPluginsLink(state)).toBe(
"http://localhost:8081/scm/api/v2/ui/plugins"
);
});
it("should return ui plugins links when unauthenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getUiPluginsLink(state)).toBe(
"http://localhost:8081/scm/api/v2/ui/plugins"
);
});
// me link
it("should return me link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/");
});
it("should return undefined for me link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getMeLink(state)).toBe(undefined);
});
// logout link
it("should return logout link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getLogoutLink(state)).toBe(
"http://localhost:8081/scm/api/v2/auth/access_token"
);
});
it("should return undefined for logout link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getLogoutLink(state)).toBe(undefined);
});
// login link
it("should return login link when unauthenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getLoginLink(state)).toBe(
"http://localhost:8081/scm/api/v2/auth/access_token"
);
});
it("should return undefined for login link when authenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getLoginLink(state)).toBe(undefined);
});
// users link
it("should return users link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getUsersLink(state)).toBe(
"http://localhost:8081/scm/api/v2/users/"
);
});
it("should return undefined for users link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getUsersLink(state)).toBe(undefined);
});
// groups link
it("should return groups link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getGroupsLink(state)).toBe(
"http://localhost:8081/scm/api/v2/groups/"
);
});
it("should return undefined for groups link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getGroupsLink(state)).toBe(undefined);
});
// config link
it("should return config link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getConfigLink(state)).toBe(
"http://localhost:8081/scm/api/v2/config"
);
});
it("should return undefined for config link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getConfigLink(state)).toBe(undefined);
});
// repositories link
it("should return repositories link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getRepositoriesLink(state)).toBe(
"http://localhost:8081/scm/api/v2/repositories/"
);
});
it("should return config for repositories link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getRepositoriesLink(state)).toBe(undefined);
});
// hgConfig link
it("should return hgConfig link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getHgConfigLink(state)).toBe(
"http://localhost:8081/scm/api/v2/config/hg"
);
});
it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getHgConfigLink(state)).toBe(undefined);
});
// gitConfig link
it("should return gitConfig link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getGitConfigLink(state)).toBe(
"http://localhost:8081/scm/api/v2/config/git"
);
});
it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getGitConfigLink(state)).toBe(undefined);
});
// svnConfig link
it("should return svnConfig link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getSvnConfigLink(state)).toBe(
"http://localhost:8081/scm/api/v2/config/svn"
);
});
it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getSvnConfigLink(state)).toBe(undefined);
});
// Autocomplete links
it("should return link collection", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getLinkCollection(state, "autocomplete")).toEqual(
indexResourcesAuthenticated._links.autocomplete
);
});
it("should return user autocomplete link", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getUserAutoCompleteLink(state)).toEqual(
"http://localhost:8081/scm/api/v2/autocomplete/users"
);
});
it("should return group autocomplete link", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getGroupAutoCompleteLink(state)).toEqual(
"http://localhost:8081/scm/api/v2/autocomplete/groups"
);
}); });
}); });
}); });
describe("index resources reducer", () => {
it("should return empty object, if state and action is undefined", () => {
expect(reducer()).toEqual({});
});
it("should return the same state, if the action is undefined", () => {
const state = { x: true };
expect(reducer(state)).toBe(state);
});
it("should return the same state, if the action is unknown to the reducer", () => {
const state = { x: true };
expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state);
});
it("should store the index resources on FETCH_INDEXRESOURCES_SUCCESS", () => {
const newState = reducer(
{},
fetchIndexResourcesSuccess(indexResourcesAuthenticated)
);
expect(newState.links).toBe(indexResourcesAuthenticated._links);
});
});
describe("index resources selectors", () => {
const error = new Error("something goes wrong");
it("should return true, when fetch index resources is pending", () => {
const state = {
pending: {
[FETCH_INDEXRESOURCES]: true
}
};
expect(isFetchIndexResourcesPending(state)).toEqual(true);
});
it("should return false, when fetch index resources is not pending", () => {
expect(isFetchIndexResourcesPending({})).toEqual(false);
});
it("should return error when fetch index resources did fail", () => {
const state = {
failure: {
[FETCH_INDEXRESOURCES]: error
}
};
expect(getFetchIndexResourcesFailure(state)).toEqual(error);
});
it("should return undefined when fetch index resources did not fail", () => {
expect(getFetchIndexResourcesFailure({})).toBe(undefined);
});
it("should return all links", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getLinks(state)).toBe(indexResourcesAuthenticated._links);
});
// ui plugins link
it("should return ui plugins link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getUiPluginsLink(state)).toBe(
"http://localhost:8081/scm/api/v2/ui/plugins"
);
});
it("should return ui plugins links when unauthenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getUiPluginsLink(state)).toBe(
"http://localhost:8081/scm/api/v2/ui/plugins"
);
});
// me link
it("should return me link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getMeLink(state)).toBe("http://localhost:8081/scm/api/v2/me/");
});
it("should return undefined for me link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getMeLink(state)).toBe(undefined);
});
// logout link
it("should return logout link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getLogoutLink(state)).toBe(
"http://localhost:8081/scm/api/v2/auth/access_token"
);
});
it("should return undefined for logout link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getLogoutLink(state)).toBe(undefined);
});
// login link
it("should return login link when unauthenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getLoginLink(state)).toBe(
"http://localhost:8081/scm/api/v2/auth/access_token"
);
});
it("should return undefined for login link when authenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getLoginLink(state)).toBe(undefined);
});
// users link
it("should return users link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getUsersLink(state)).toBe("http://localhost:8081/scm/api/v2/users/");
});
it("should return undefined for users link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getUsersLink(state)).toBe(undefined);
});
// groups link
it("should return groups link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getGroupsLink(state)).toBe("http://localhost:8081/scm/api/v2/groups/");
});
it("should return undefined for groups link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getGroupsLink(state)).toBe(undefined);
});
// config link
it("should return config link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getConfigLink(state)).toBe(
"http://localhost:8081/scm/api/v2/config"
);
});
it("should return undefined for config link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getConfigLink(state)).toBe(undefined);
});
// repositories link
it("should return repositories link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getRepositoriesLink(state)).toBe(
"http://localhost:8081/scm/api/v2/repositories/"
);
});
it("should return config for repositories link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getRepositoriesLink(state)).toBe(undefined);
});
// hgConfig link
it("should return hgConfig link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getHgConfigLink(state)).toBe(
"http://localhost:8081/scm/api/v2/config/hg"
);
});
it("should return config for hgConfig link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getHgConfigLink(state)).toBe(undefined);
});
// gitConfig link
it("should return gitConfig link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getGitConfigLink(state)).toBe(
"http://localhost:8081/scm/api/v2/config/git"
);
});
it("should return config for gitConfig link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getGitConfigLink(state)).toBe(undefined);
});
// svnConfig link
it("should return svnConfig link when authenticated and has permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesAuthenticated._links
}
};
expect(getSvnConfigLink(state)).toBe(
"http://localhost:8081/scm/api/v2/config/svn"
);
});
it("should return config for svnConfig link when unauthenticated or has not permission to see it", () => {
const state = {
indexResources: {
links: indexResourcesUnauthenticated._links
}
};
expect(getSvnConfigLink(state)).toBe(undefined);
});
});

View File

@@ -3,16 +3,20 @@ import React from "react";
import type { Changeset, Repository } from "@scm-manager/ui-types"; import type { Changeset, Repository } from "@scm-manager/ui-types";
import { Interpolate, translate } from "react-i18next"; import { Interpolate, translate } from "react-i18next";
import injectSheet from "react-jss"; import injectSheet from "react-jss";
import ChangesetTag from "./ChangesetTag";
import ChangesetAuthor from "./ChangesetAuthor"; import {
import { parseDescription } from "./changesets"; DateFromNow,
import { DateFromNow } from "@scm-manager/ui-components"; ChangesetId,
import AvatarWrapper from "./AvatarWrapper"; ChangesetTag,
import AvatarImage from "./AvatarImage"; ChangesetAuthor,
ChangesetDiff,
AvatarWrapper,
AvatarImage,
changesets,
} from "@scm-manager/ui-components";
import classNames from "classnames"; import classNames from "classnames";
import ChangesetId from "./ChangesetId";
import type { Tag } from "@scm-manager/ui-types"; import type { Tag } from "@scm-manager/ui-types";
import ScmDiff from "../../containers/ScmDiff";
const styles = { const styles = {
spacing: { spacing: {
@@ -31,12 +35,12 @@ class ChangesetDetails extends React.Component<Props> {
render() { render() {
const { changeset, repository, classes } = this.props; const { changeset, repository, classes } = this.props;
const description = parseDescription(changeset.description); const description = changesets.parseDescription(changeset.description);
const id = ( const id = (
<ChangesetId repository={repository} changeset={changeset} link={false} /> <ChangesetId repository={repository} changeset={changeset} link={false}/>
); );
const date = <DateFromNow date={changeset.date} />; const date = <DateFromNow date={changeset.date}/>;
return ( return (
<div> <div>
@@ -45,12 +49,12 @@ class ChangesetDetails extends React.Component<Props> {
<article className="media"> <article className="media">
<AvatarWrapper> <AvatarWrapper>
<p className={classNames("image", "is-64x64", classes.spacing)}> <p className={classNames("image", "is-64x64", classes.spacing)}>
<AvatarImage changeset={changeset} /> <AvatarImage person={changeset.author} />
</p> </p>
</AvatarWrapper> </AvatarWrapper>
<div className="media-content"> <div className="media-content">
<p> <p>
<ChangesetAuthor changeset={changeset} /> <ChangesetAuthor changeset={changeset}/>
</p> </p>
<p> <p>
<Interpolate <Interpolate
@@ -67,14 +71,14 @@ class ChangesetDetails extends React.Component<Props> {
return ( return (
<span key={key}> <span key={key}>
{item} {item}
<br /> <br/>
</span> </span>
); );
})} })}
</p> </p>
</div> </div>
<div> <div>
<ScmDiff changeset={changeset} sideBySide={false} /> <ChangesetDiff changeset={changeset} />
</div> </div>
</div> </div>
); );
@@ -91,7 +95,7 @@ class ChangesetDetails extends React.Component<Props> {
return ( return (
<div className="level-item"> <div className="level-item">
{tags.map((tag: Tag) => { {tags.map((tag: Tag) => {
return <ChangesetTag key={tag.name} tag={tag} />; return <ChangesetTag key={tag.name} tag={tag}/>;
})} })}
</div> </div>
); );

View File

@@ -12,6 +12,9 @@ const styles = {
zeroflex: { zeroflex: {
flexGrow: 0 flexGrow: 0
}, },
minWidthOfLabel: {
minWidth: "4.5rem"
},
wrapper: { wrapper: {
padding: "1rem 1.5rem 0.25rem 1.5rem", padding: "1rem 1.5rem 0.25rem 1.5rem",
border: "1px solid #eee", border: "1px solid #eee",

View File

@@ -12,8 +12,7 @@ import {
} from "../modules/changesets"; } from "../modules/changesets";
import {connect} from "react-redux"; import {connect} from "react-redux";
import ChangesetList from "../components/changesets/ChangesetList"; import {ErrorNotification, getPageFromMatch, LinkPaginator, ChangesetList, Loading} from "@scm-manager/ui-components";
import {ErrorNotification, getPageFromMatch, LinkPaginator, Loading} from "@scm-manager/ui-components";
import {compose} from "redux"; import {compose} from "redux";
type Props = { type Props = {

View File

@@ -114,7 +114,7 @@ class RepositoryRoot extends React.Component<Props> {
return ( return (
<Page title={repository.namespace + "/" + repository.name}> <Page title={repository.namespace + "/" + repository.name}>
<div className="columns"> <div className="columns">
<div className="column is-three-quarters"> <div className="column is-three-quarters is-clipped">
<Switch> <Switch>
<Route <Route
path={url} path={url}

View File

@@ -1,51 +0,0 @@
// @flow
import React from "react";
import { apiClient } from "@scm-manager/ui-components";
import type { Changeset } from "@scm-manager/ui-types";
import { Diff2Html } from "diff2html";
type Props = {
changeset: Changeset,
sideBySide: boolean
};
type State = {
diff: string,
error?: Error
};
class ScmDiff extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { diff: "" };
}
componentDidMount() {
const { changeset } = this.props;
const url = changeset._links.diff.href+"?format=GIT";
apiClient
.get(url)
.then(response => response.text())
.then(text => this.setState({ ...this.state, diff: text }))
.catch(error => this.setState({ ...this.state, error }));
}
render() {
const options = {
inputFormat: "diff",
outputFormat: this.props.sideBySide ? "side-by-side" : "line-by-line",
showFiles: false,
matching: "lines"
};
const outputHtml = Diff2Html.getPrettyHtml(this.state.diff, options);
return (
// eslint-disable-next-line react/no-danger
<div dangerouslySetInnerHTML={{ __html: outputHtml }} />
);
}
}
export default ScmDiff;

View File

@@ -224,9 +224,8 @@ export function modifyRepo(repository: Repository, callback?: () => void) {
.then(() => { .then(() => {
dispatch(fetchRepoByLink(repository)); dispatch(fetchRepoByLink(repository));
}) })
.catch(cause => { .catch(err => {
const error = new Error(`failed to modify repo: ${cause.message}`); dispatch(modifyRepoFailure(repository, err));
dispatch(modifyRepoFailure(repository, error));
}); });
}; };
} }

View File

@@ -1,23 +1,30 @@
// @flow // @flow
import React from "react"; import React from "react";
import {translate} from "react-i18next"; import { translate } from "react-i18next";
import {Checkbox, InputField, SubmitButton} from "@scm-manager/ui-components"; import { Autocomplete, SubmitButton } from "@scm-manager/ui-components";
import TypeSelector from "./TypeSelector"; import TypeSelector from "./TypeSelector";
import type {PermissionCollection, PermissionCreateEntry} from "@scm-manager/ui-types"; import type {
PermissionCollection,
PermissionCreateEntry,
SelectValue
} from "@scm-manager/ui-types";
import * as validator from "./permissionValidation"; import * as validator from "./permissionValidation";
type Props = { type Props = {
t: string => string, t: string => string,
createPermission: (permission: PermissionCreateEntry) => void, createPermission: (permission: PermissionCreateEntry) => void,
loading: boolean, loading: boolean,
currentPermissions: PermissionCollection currentPermissions: PermissionCollection,
groupAutoCompleteLink: string,
userAutoCompleteLink: string
}; };
type State = { type State = {
name: string, name: string,
type: string, type: string,
groupPermission: boolean, groupPermission: boolean,
valid: boolean valid: boolean,
value?: SelectValue
}; };
class CreatePermissionForm extends React.Component<Props, State> { class CreatePermissionForm extends React.Component<Props, State> {
@@ -28,13 +35,95 @@ class CreatePermissionForm extends React.Component<Props, State> {
name: "", name: "",
type: "READ", type: "READ",
groupPermission: false, groupPermission: false,
valid: true valid: true,
value: undefined
}; };
} }
permissionScopeChanged = event => {
const groupPermission = event.target.value === "GROUP_PERMISSION";
this.setState({
groupPermission: groupPermission,
valid: validator.isPermissionValid(
this.state.name,
groupPermission,
this.props.currentPermissions
)
});
this.setState({ ...this.state, groupPermission });
};
loadUserAutocompletion = (inputValue: string) => {
return this.loadAutocompletion(this.props.userAutoCompleteLink, inputValue);
};
loadGroupAutocompletion = (inputValue: string) => {
return this.loadAutocompletion(
this.props.groupAutoCompleteLink,
inputValue
);
};
loadAutocompletion(url: string, inputValue: string) {
const link = url + "?q=";
return fetch(link + inputValue)
.then(response => response.json())
.then(json => {
return json.map(element => {
const label = element.displayName
? `${element.displayName} (${element.id})`
: element.id;
return {
value: element,
label
};
});
});
}
renderAutocompletionField = () => {
const { t } = this.props;
if (this.state.groupPermission) {
return (
<Autocomplete
loadSuggestions={this.loadGroupAutocompletion}
valueSelected={this.groupOrUserSelected}
value={this.state.value}
label={t("permission.group")}
noOptionsMessage={t("permission.autocomplete.no-group-options")}
loadingMessage={t("permission.autocomplete.loading")}
placeholder={t("permission.autocomplete.group-placeholder")}
/>
);
}
return (
<Autocomplete
loadSuggestions={this.loadUserAutocompletion}
valueSelected={this.groupOrUserSelected}
value={this.state.value}
label={t("permission.user")}
noOptionsMessage={t("permission.autocomplete.no-user-options")}
loadingMessage={t("permission.autocomplete.loading")}
placeholder={t("permission.autocomplete.user-placeholder")}
/>
);
};
groupOrUserSelected = (value: SelectValue) => {
this.setState({
value,
name: value.value.id,
valid: validator.isPermissionValid(
value.value.id,
this.state.groupPermission,
this.props.currentPermissions
)
});
};
render() { render() {
const { t, loading } = this.props; const { t, loading } = this.props;
const { name, type, groupPermission } = this.state;
const { type } = this.state;
return ( return (
<div> <div>
@@ -43,23 +132,32 @@ class CreatePermissionForm extends React.Component<Props, State> {
{t("permission.add-permission.add-permission-heading")} {t("permission.add-permission.add-permission-heading")}
</h2> </h2>
<form onSubmit={this.submit}> <form onSubmit={this.submit}>
<div className="control">
<label className="radio">
<input
type="radio"
name="permission_scope"
checked={!this.state.groupPermission}
value="USER_PERMISSION"
onChange={this.permissionScopeChanged}
/>
{t("permission.user-permission")}
</label>
<label className="radio">
<input
type="radio"
name="permission_scope"
value="GROUP_PERMISSION"
checked={this.state.groupPermission}
onChange={this.permissionScopeChanged}
/>
{t("permission.group-permission")}
</label>
</div>
<div class="columns"> <div class="columns">
<div class="column is-three-quarters"> <div class="column is-three-quarters">
<InputField {this.renderAutocompletionField()}
label={t("permission.name")}
value={name ? name : ""}
onChange={this.handleNameChange}
validationError={!this.state.valid}
errorMessage={t("permission.add-permission.name-input-invalid")}
helpText={t("permission.help.nameHelpText")}
/>
<Checkbox
label={t("permission.group-permission")}
checked={groupPermission ? groupPermission : false}
onChange={this.handleGroupPermissionChange}
helpText={t("permission.help.groupPermissionHelpText")}
/>
</div> </div>
<div class="column is-one-quarter"> <div class="column is-one-quarter">
<TypeSelector <TypeSelector
@@ -108,27 +206,6 @@ class CreatePermissionForm extends React.Component<Props, State> {
type: type type: type
}); });
}; };
handleNameChange = (name: string) => {
this.setState({
name: name,
valid: validator.isPermissionValid(
name,
this.state.groupPermission,
this.props.currentPermissions
)
});
};
handleGroupPermissionChange = (groupPermission: boolean) => {
this.setState({
groupPermission: groupPermission,
valid: validator.isPermissionValid(
this.state.name,
groupPermission,
this.props.currentPermissions
)
});
};
} }
export default translate("repos")(CreatePermissionForm); export default translate("repos")(CreatePermissionForm);

View File

@@ -27,6 +27,10 @@ import SinglePermission from "./SinglePermission";
import CreatePermissionForm from "../components/CreatePermissionForm"; import CreatePermissionForm from "../components/CreatePermissionForm";
import type { History } from "history"; import type { History } from "history";
import { getPermissionsLink } from "../../modules/repos"; import { getPermissionsLink } from "../../modules/repos";
import {
getGroupAutoCompleteLink,
getUserAutoCompleteLink
} from "../../../modules/indexResource";
type Props = { type Props = {
namespace: string, namespace: string,
@@ -37,6 +41,8 @@ type Props = {
hasPermissionToCreate: boolean, hasPermissionToCreate: boolean,
loadingCreatePermission: boolean, loadingCreatePermission: boolean,
permissionsLink: string, permissionsLink: string,
groupAutoCompleteLink: string,
userAutoCompleteLink: string,
//dispatch functions //dispatch functions
fetchPermissions: (link: string, namespace: string, repoName: string) => void, fetchPermissions: (link: string, namespace: string, repoName: string) => void,
@@ -92,7 +98,9 @@ class Permissions extends React.Component<Props> {
namespace, namespace,
repoName, repoName,
loadingCreatePermission, loadingCreatePermission,
hasPermissionToCreate hasPermissionToCreate,
userAutoCompleteLink,
groupAutoCompleteLink
} = this.props; } = this.props;
if (error) { if (error) {
return ( return (
@@ -113,6 +121,8 @@ class Permissions extends React.Component<Props> {
createPermission={permission => this.createPermission(permission)} createPermission={permission => this.createPermission(permission)}
loading={loadingCreatePermission} loading={loadingCreatePermission}
currentPermissions={permissions} currentPermissions={permissions}
userAutoCompleteLink={userAutoCompleteLink}
groupAutoCompleteLink={groupAutoCompleteLink}
/> />
) : null; ) : null;
@@ -165,6 +175,8 @@ const mapStateToProps = (state, ownProps) => {
); );
const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName); const hasPermissionToCreate = hasCreatePermission(state, namespace, repoName);
const permissionsLink = getPermissionsLink(state, namespace, repoName); const permissionsLink = getPermissionsLink(state, namespace, repoName);
const groupAutoCompleteLink = getGroupAutoCompleteLink(state);
const userAutoCompleteLink = getUserAutoCompleteLink(state);
return { return {
namespace, namespace,
repoName, repoName,
@@ -173,7 +185,9 @@ const mapStateToProps = (state, ownProps) => {
permissions, permissions,
hasPermissionToCreate, hasPermissionToCreate,
loadingCreatePermission, loadingCreatePermission,
permissionsLink permissionsLink,
groupAutoCompleteLink,
userAutoCompleteLink
}; };
}; };
@@ -189,7 +203,9 @@ const mapDispatchToProps = dispatch => {
repoName: string, repoName: string,
callback?: () => void callback?: () => void
) => { ) => {
dispatch(createPermission(link, permission, namespace, repoName, callback)); dispatch(
createPermission(link, permission, namespace, repoName, callback)
);
}, },
createPermissionReset: (namespace: string, repoName: string) => { createPermissionReset: (namespace: string, repoName: string) => {
dispatch(createPermissionReset(namespace, repoName)); dispatch(createPermissionReset(namespace, repoName));

Some files were not shown because too many files have changed in this diff Show More