Merge with 2.0.0-m3

This commit is contained in:
René Pfeuffer
2018-11-08 09:21:41 +01:00
71 changed files with 1812 additions and 1548 deletions

View File

@@ -398,6 +398,11 @@ public final class LogCommandBuilder
return this; return this;
} }
public LogCommandBuilder setAncestorChangeset(String ancestorChangeset) {
request.setAncestorChangeset(ancestorChangeset);
return this;
}
//~--- inner classes -------------------------------------------------------- //~--- inner classes --------------------------------------------------------
/** /**

View File

@@ -109,7 +109,10 @@ public final class DiffCommandRequest extends FileBaseCommandRequest
this.format = format; this.format = format;
} }
//~--- get methods ---------------------------------------------------------- public void setAncestorChangeset(String ancestorChangeset) {
this.ancestorChangeset = ancestorChangeset;
}
//~--- get methods ----------------------------------------------------------
/** /**
* Return the output format of the diff command. * Return the output format of the diff command.
@@ -124,8 +127,13 @@ public final class DiffCommandRequest extends FileBaseCommandRequest
return format; return format;
} }
//~--- fields --------------------------------------------------------------- public String getAncestorChangeset() {
return ancestorChangeset;
}
//~--- fields ---------------------------------------------------------------
/** diff format */ /** diff format */
private DiffFormat format = DiffFormat.NATIVE; private DiffFormat format = DiffFormat.NATIVE;
private String ancestorChangeset;
} }

View File

@@ -84,7 +84,8 @@ public final class LogCommandRequest implements Serializable, Resetable
&& Objects.equal(pagingStart, other.pagingStart) && Objects.equal(pagingStart, other.pagingStart)
&& Objects.equal(pagingLimit, other.pagingLimit) && Objects.equal(pagingLimit, other.pagingLimit)
&& Objects.equal(path, other.path) && Objects.equal(path, other.path)
&& Objects.equal(branch, other.branch); && Objects.equal(branch, other.branch)
&& Objects.equal(ancestorChangeset, other.ancestorChangeset);
//J+ //J+
} }
@@ -98,7 +99,7 @@ public final class LogCommandRequest implements Serializable, Resetable
public int hashCode() public int hashCode()
{ {
return Objects.hashCode(startChangeset, endChangeset, pagingStart, return Objects.hashCode(startChangeset, endChangeset, pagingStart,
pagingLimit, path, branch); pagingLimit, path, branch, ancestorChangeset);
} }
/** /**
@@ -114,6 +115,7 @@ public final class LogCommandRequest implements Serializable, Resetable
pagingLimit = 20; pagingLimit = 20;
path = null; path = null;
branch = null; branch = null;
ancestorChangeset = null;
} }
/** /**
@@ -133,6 +135,7 @@ public final class LogCommandRequest implements Serializable, Resetable
.add("pagingLimit", pagingLimit) .add("pagingLimit", pagingLimit)
.add("path", path) .add("path", path)
.add("branch", branch) .add("branch", branch)
.add("ancestorChangeset", ancestorChangeset)
.toString(); .toString();
//J+ //J+
} }
@@ -205,6 +208,10 @@ public final class LogCommandRequest implements Serializable, Resetable
this.startChangeset = startChangeset; this.startChangeset = startChangeset;
} }
public void setAncestorChangeset(String ancestorChangeset) {
this.ancestorChangeset = ancestorChangeset;
}
//~--- get methods ---------------------------------------------------------- //~--- get methods ----------------------------------------------------------
/** /**
@@ -284,6 +291,10 @@ public final class LogCommandRequest implements Serializable, Resetable
return pagingLimit < 0; return pagingLimit < 0;
} }
public String getAncestorChangeset() {
return ancestorChangeset;
}
//~--- fields --------------------------------------------------------------- //~--- fields ---------------------------------------------------------------
/** Field description */ /** Field description */
@@ -303,4 +314,6 @@ public final class LogCommandRequest implements Serializable, Resetable
/** Field description */ /** Field description */
private String startChangeset; private String startChangeset;
private String ancestorChangeset;
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@scm-manager/scm-git-plugin", "name": "@scm-manager/scm-git-plugin",
"license" : "BSD-3-Clause", "license": "BSD-3-Clause",
"main": "src/main/js/index.js", "main": "src/main/js/index.js",
"scripts": { "scripts": {
"build": "ui-bundler plugin", "build": "ui-bundler plugin",
@@ -9,9 +9,9 @@
"flow": "flow check" "flow": "flow check"
}, },
"dependencies": { "dependencies": {
"@scm-manager/ui-extensions": "^0.0.7" "@scm-manager/ui-extensions": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.19" "@scm-manager/ui-bundler": "^0.0.21"
} }
} }

View File

@@ -119,17 +119,17 @@ public class GitRepositoryHandler
public void init(SCMContextProvider context) public void init(SCMContextProvider context)
{ {
super.init(context); super.init(context);
scheduleGc(); scheduleGc(getConfig().getGcExpression());
} }
@Override @Override
public void setConfig(GitConfig config) public void setConfig(GitConfig config)
{ {
scheduleGc(config.getGcExpression());
super.setConfig(config); super.setConfig(config);
scheduleGc();
} }
private void scheduleGc() private void scheduleGc(String expression)
{ {
synchronized (LOCK){ synchronized (LOCK){
if ( task != null ){ if ( task != null ){
@@ -137,11 +137,10 @@ public class GitRepositoryHandler
task.cancel(); task.cancel();
task = null; task = null;
} }
String exp = getConfig().getGcExpression(); if (!Strings.isNullOrEmpty(expression))
if (!Strings.isNullOrEmpty(exp))
{ {
logger.info("schedule git gc task with expression {}", exp); logger.info("schedule git gc task with expression {}", expression);
task = scheduler.schedule(exp, GitGcTask.class); task = scheduler.schedule(expression, GitGcTask.class);
} }
} }
} }

View File

@@ -48,6 +48,7 @@ import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.revwalk.filter.RevFilter;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.eclipse.jgit.transport.FetchResult; import org.eclipse.jgit.transport.FetchResult;
import org.eclipse.jgit.transport.RefSpec; import org.eclipse.jgit.transport.RefSpec;
@@ -716,6 +717,18 @@ public final class GitUtil
return (id != null) &&!id.equals(ObjectId.zeroId()); return (id != null) &&!id.equals(ObjectId.zeroId());
} }
/**
* Computes the first common ancestor of two revisions, aka merge base.
*/
public static ObjectId computeCommonAncestor(org.eclipse.jgit.lib.Repository repository, ObjectId revision1, ObjectId revision2) throws IOException {
try (RevWalk mergeBaseWalk = new RevWalk(repository)) {
mergeBaseWalk.setRevFilter(RevFilter.MERGE_BASE);
mergeBaseWalk.markStart(mergeBaseWalk.lookupCommit(revision1));
mergeBaseWalk.markStart(mergeBaseWalk.parseCommit(revision2));
return mergeBaseWalk.next().getId();
}
}
//~--- methods -------------------------------------------------------------- //~--- methods --------------------------------------------------------------
/** /**

View File

@@ -34,27 +34,25 @@ package sonia.scm.repository.spi;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.Strings;
import org.eclipse.jgit.diff.DiffEntry; import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffFormatter; import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.EmptyTreeIterator; import org.eclipse.jgit.treewalk.EmptyTreeIterator;
import org.eclipse.jgit.treewalk.TreeWalk; import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.PathFilter; import org.eclipse.jgit.treewalk.filter.PathFilter;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.repository.GitUtil; import sonia.scm.repository.GitUtil;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.util.Util; import sonia.scm.util.Util;
//~--- JDK imports ------------------------------------------------------------
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.util.List; import java.util.List;
/** /**
@@ -107,7 +105,8 @@ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand
walk = new RevWalk(gr); walk = new RevWalk(gr);
RevCommit commit = walk.parseCommit(gr.resolve(request.getRevision())); ObjectId revision = gr.resolve(request.getRevision());
RevCommit commit = walk.parseCommit(revision);
walk.markStart(commit); walk.markStart(commit);
commit = walk.next(); commit = walk.next();
@@ -120,7 +119,15 @@ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand
treeWalk.setFilter(PathFilter.create(request.getPath())); treeWalk.setFilter(PathFilter.create(request.getPath()));
} }
if (commit.getParentCount() > 0)
if (!Strings.isNullOrEmpty(request.getAncestorChangeset()))
{
ObjectId otherRevision = gr.resolve(request.getAncestorChangeset());
ObjectId ancestorId = computeCommonAncestor(gr, revision, otherRevision);
RevTree tree = walk.parseCommit(ancestorId).getTree();
treeWalk.addTree(tree);
}
else if (commit.getParentCount() > 0)
{ {
RevTree tree = commit.getParent(0).getTree(); RevTree tree = commit.getParent(0).getTree();
@@ -156,7 +163,6 @@ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand
} }
catch (Exception ex) catch (Exception ex)
{ {
// TODO throw exception // TODO throw exception
logger.error("could not create diff", ex); logger.error("could not create diff", ex);
} }
@@ -167,4 +173,9 @@ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand
GitUtil.release(formatter); GitUtil.release(formatter);
} }
} }
private ObjectId computeCommonAncestor(org.eclipse.jgit.lib.Repository repository, ObjectId revision1, ObjectId revision2) throws IOException {
return GitUtil.computeCommonAncestor(repository, revision1, revision2);
}
} }

View File

@@ -43,6 +43,7 @@ import org.eclipse.jgit.lib.Ref;
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.revwalk.RevWalk; import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.revwalk.filter.RevFilter;
import org.eclipse.jgit.treewalk.filter.AndTreeFilter; import org.eclipse.jgit.treewalk.filter.AndTreeFilter;
import org.eclipse.jgit.treewalk.filter.PathFilter; import org.eclipse.jgit.treewalk.filter.PathFilter;
import org.eclipse.jgit.treewalk.filter.TreeFilter; import org.eclipse.jgit.treewalk.filter.TreeFilter;
@@ -198,6 +199,14 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
endId = repository.resolve(request.getEndChangeset()); endId = repository.resolve(request.getEndChangeset());
} }
Ref branch = getBranchOrDefault(repository,request.getBranch());
ObjectId ancestorId = null;
if (!Strings.isNullOrEmpty(request.getAncestorChangeset())) {
ancestorId = computeCommonAncestor(request, repository, startId, branch);
}
revWalk = new RevWalk(repository); revWalk = new RevWalk(repository);
converter = new GitChangesetConverter(repository, revWalk); converter = new GitChangesetConverter(repository, revWalk);
@@ -208,8 +217,6 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
PathFilter.create(request.getPath()), TreeFilter.ANY_DIFF)); PathFilter.create(request.getPath()), TreeFilter.ANY_DIFF));
} }
Ref branch = getBranchOrDefault(repository,request.getBranch());
if (branch != null) { if (branch != null) {
if (startId != null) { if (startId != null) {
revWalk.markStart(revWalk.lookupCommit(startId)); revWalk.markStart(revWalk.lookupCommit(startId));
@@ -217,11 +224,16 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
revWalk.markStart(revWalk.lookupCommit(branch.getObjectId())); revWalk.markStart(revWalk.lookupCommit(branch.getObjectId()));
} }
Iterator<RevCommit> iterator = revWalk.iterator(); Iterator<RevCommit> iterator = revWalk.iterator();
while (iterator.hasNext()) { while (iterator.hasNext()) {
RevCommit commit = iterator.next(); RevCommit commit = iterator.next();
if (commit.getId().equals(ancestorId)) {
break;
}
if ((counter >= start) if ((counter >= start)
&& ((limit < 0) || (counter < start + limit))) { && ((limit < 0) || (counter < start + limit))) {
changesetList.add(converter.createChangeset(commit)); changesetList.add(converter.createChangeset(commit));
@@ -229,7 +241,7 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
counter++; counter++;
if ((endId != null) && commit.getId().equals(endId)) { if (commit.getId().equals(endId)) {
break; break;
} }
} }
@@ -263,4 +275,17 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
return changesets; return changesets;
} }
private ObjectId computeCommonAncestor(LogCommandRequest request, Repository repository, ObjectId startId, Ref branch) throws IOException {
try (RevWalk mergeBaseWalk = new RevWalk(repository)) {
mergeBaseWalk.setRevFilter(RevFilter.MERGE_BASE);
if (startId != null) {
mergeBaseWalk.markStart(mergeBaseWalk.lookupCommit(startId));
} else {
mergeBaseWalk.markStart(mergeBaseWalk.lookupCommit(branch.getObjectId()));
}
mergeBaseWalk.markStart(mergeBaseWalk.parseCommit(repository.resolve(request.getAncestorChangeset())));
return mergeBaseWalk.next().getId();
}
}
} }

View File

@@ -0,0 +1,79 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { Links } from "@scm-manager/ui-types";
import { InputField, Checkbox } from "@scm-manager/ui-components";
type Configuration = {
repositoryDirectory?: string,
gcExpression?: string,
disabled: boolean,
_links: Links
}
type Props = {
initialConfiguration: Configuration,
readOnly: boolean,
onConfigurationChange: (Configuration, boolean) => void,
// context props
t: (string) => string
}
type State = Configuration & {
}
class GitConfigurationForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { ...props.initialConfiguration };
}
isValid = () => {
return !!this.state.repositoryDirectory;
};
handleChange = (value: any, name: string) => {
this.setState({
[name]: value
}, () => this.props.onConfigurationChange(this.state, this.isValid()));
};
render() {
const { repositoryDirectory, gcExpression, disabled } = this.state;
const { readOnly, t } = this.props;
return (
<>
<InputField name="repositoryDirectory"
label={t("scm-git-plugin.config.directory")}
helpText={t("scm-git-plugin.config.directoryHelpText")}
value={repositoryDirectory}
onChange={this.handleChange}
disabled={readOnly}
/>
<InputField name="gcExpression"
label={t("scm-git-plugin.config.gcExpression")}
helpText={t("scm-git-plugin.config.gcExpressionHelpText")}
value={gcExpression}
onChange={this.handleChange}
disabled={readOnly}
/>
<Checkbox name="disabled"
label={t("scm-git-plugin.config.disabled")}
helpText={t("scm-git-plugin.config.disabledHelpText")}
checked={disabled}
onChange={this.handleChange}
disabled={readOnly}
/>
</>
);
}
}
export default translate("plugins")(GitConfigurationForm);

View File

@@ -0,0 +1,32 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { Title, GlobalConfiguration } from "@scm-manager/ui-components";
import GitConfigurationForm from "./GitConfigurationForm";
type Props = {
link: string,
t: (string) => string
};
class GitGlobalConfiguration extends React.Component<Props> {
constructor(props: Props) {
super(props);
}
render() {
const { link, t } = this.props;
return (
<div>
<Title title={t("scm-git-plugin.config.title")}/>
<GlobalConfiguration link={link} render={props => <GitConfigurationForm {...props} />}/>
</div>
);
}
}
export default translate("plugins")(GitGlobalConfiguration);

View File

@@ -3,9 +3,18 @@ import { binder } from "@scm-manager/ui-extensions";
import ProtocolInformation from "./ProtocolInformation"; import ProtocolInformation from "./ProtocolInformation";
import GitAvatar from "./GitAvatar"; import GitAvatar from "./GitAvatar";
import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components";
import GitGlobalConfiguration from "./GitGlobalConfiguration";
// repository
const gitPredicate = (props: Object) => { const gitPredicate = (props: Object) => {
return props.repository && props.repository.type === "git"; return props.repository && props.repository.type === "git";
}; };
binder.bind("repos.repository-details.information", ProtocolInformation, gitPredicate); binder.bind("repos.repository-details.information", ProtocolInformation, gitPredicate);
binder.bind("repos.repository-avatar", GitAvatar, gitPredicate); binder.bind("repos.repository-avatar", GitAvatar, gitPredicate);
// global config
cfgBinder.bindGlobal("/git", "scm-git-plugin.config.link", "gitConfig", GitGlobalConfiguration);

View File

@@ -4,6 +4,17 @@
"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"
},
"config": {
"link": "Git",
"title": "Git Configuration",
"directory": "Repository Directory",
"directoryHelpText": "Location of the Git repositories.",
"gcExpression": "GC Cron Expression",
"gcExpressionHelpText": "Use Quartz Cron Expressions (SECOND MINUTE HOUR DAYOFMONTH MONTH DAYOFWEEK) to run git gc in intervals.",
"disabled": "Disabled",
"disabledHelpText": "Enable or disable the Git plugin",
"submit": "Submit"
} }
} }
} }

View File

@@ -0,0 +1,93 @@
package sonia.scm.repository.spi;
import org.junit.Test;
import java.io.ByteArrayOutputStream;
import static org.junit.Assert.assertEquals;
public class GitDiffCommandTest extends AbstractGitCommandTestBase {
public static final String DIFF_FILE_A = "diff --git a/a.txt b/a.txt\n" +
"index 7898192..1dc60c7 100644\n" +
"--- a/a.txt\n" +
"+++ b/a.txt\n" +
"@@ -1 +1 @@\n" +
"-a\n" +
"+a and b\n";
public static final String DIFF_FILE_B = "diff --git a/b.txt b/b.txt\n" +
"deleted file mode 100644\n" +
"index 6178079..0000000\n" +
"--- a/b.txt\n" +
"+++ /dev/null\n" +
"@@ -1 +0,0 @@\n" +
"-b\n";
public static final String DIFF_FILE_A_MULTIPLE_REVISIONS = "diff --git a/a.txt b/a.txt\n" +
"index 7898192..2f8bc28 100644\n" +
"--- a/a.txt\n" +
"+++ b/a.txt\n" +
"@@ -1 +1,2 @@\n" +
" a\n" +
"+line for blame\n";
public static final String DIFF_FILE_F_MULTIPLE_REVISIONS = "diff --git a/f.txt b/f.txt\n" +
"new file mode 100644\n" +
"index 0000000..6a69f92\n" +
"--- /dev/null\n" +
"+++ b/f.txt\n" +
"@@ -0,0 +1 @@\n" +
"+f\n";
@Test
public void diffForOneRevisionShouldCreateDiff() {
GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext(), repository);
DiffCommandRequest diffCommandRequest = new DiffCommandRequest();
diffCommandRequest.setRevision("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4");
ByteArrayOutputStream output = new ByteArrayOutputStream();
gitDiffCommand.getDiffResult(diffCommandRequest, output);
assertEquals(DIFF_FILE_A + DIFF_FILE_B, output.toString());
}
@Test
public void diffForOneBranchShouldCreateDiff() {
GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext(), repository);
DiffCommandRequest diffCommandRequest = new DiffCommandRequest();
diffCommandRequest.setRevision("test-branch");
ByteArrayOutputStream output = new ByteArrayOutputStream();
gitDiffCommand.getDiffResult(diffCommandRequest, output);
assertEquals(DIFF_FILE_A + DIFF_FILE_B, output.toString());
}
@Test
public void diffForPathShouldCreateLimitedDiff() {
GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext(), repository);
DiffCommandRequest diffCommandRequest = new DiffCommandRequest();
diffCommandRequest.setRevision("test-branch");
diffCommandRequest.setPath("a.txt");
ByteArrayOutputStream output = new ByteArrayOutputStream();
gitDiffCommand.getDiffResult(diffCommandRequest, output);
assertEquals(DIFF_FILE_A, output.toString());
}
@Test
public void diffBetweenTwoBranchesShouldCreateDiff() {
GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext(), repository);
DiffCommandRequest diffCommandRequest = new DiffCommandRequest();
diffCommandRequest.setRevision("master");
diffCommandRequest.setAncestorChangeset("test-branch");
ByteArrayOutputStream output = new ByteArrayOutputStream();
gitDiffCommand.getDiffResult(diffCommandRequest, output);
assertEquals(DIFF_FILE_A_MULTIPLE_REVISIONS + DIFF_FILE_F_MULTIPLE_REVISIONS, output.toString());
}
@Test
public void diffBetweenTwoBranchesForPathShouldCreateLimitedDiff() {
GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext(), repository);
DiffCommandRequest diffCommandRequest = new DiffCommandRequest();
diffCommandRequest.setRevision("master");
diffCommandRequest.setAncestorChangeset("test-branch");
diffCommandRequest.setPath("a.txt");
ByteArrayOutputStream output = new ByteArrayOutputStream();
gitDiffCommand.getDiffResult(diffCommandRequest, output);
assertEquals(DIFF_FILE_A_MULTIPLE_REVISIONS, output.toString());
}
}

View File

@@ -64,7 +64,7 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
* Tests log command with the usage of a default branch. * Tests log command with the usage of a default branch.
*/ */
@Test @Test
public void testGetDefaultBranch() throws Exception { public void testGetDefaultBranch() {
// without default branch, the repository head should be used // without default branch, the repository head should be used
ChangesetPagingResult result = createCommand().getChangesets(new LogCommandRequest()); ChangesetPagingResult result = createCommand().getChangesets(new LogCommandRequest());
@@ -92,7 +92,7 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
} }
@Test @Test
public void testGetAll() throws Exception public void testGetAll()
{ {
ChangesetPagingResult result = ChangesetPagingResult result =
createCommand().getChangesets(new LogCommandRequest()); createCommand().getChangesets(new LogCommandRequest());
@@ -103,7 +103,7 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
} }
@Test @Test
public void testGetAllByPath() throws Exception public void testGetAllByPath()
{ {
LogCommandRequest request = new LogCommandRequest(); LogCommandRequest request = new LogCommandRequest();
@@ -119,7 +119,7 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
} }
@Test @Test
public void testGetAllWithLimit() throws Exception public void testGetAllWithLimit()
{ {
LogCommandRequest request = new LogCommandRequest(); LogCommandRequest request = new LogCommandRequest();
@@ -143,7 +143,7 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
} }
@Test @Test
public void testGetAllWithPaging() throws Exception public void testGetAllWithPaging()
{ {
LogCommandRequest request = new LogCommandRequest(); LogCommandRequest request = new LogCommandRequest();
@@ -194,7 +194,7 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
} }
@Test @Test
public void testGetRange() throws Exception public void testGetRange()
{ {
LogCommandRequest request = new LogCommandRequest(); LogCommandRequest request = new LogCommandRequest();
@@ -216,6 +216,26 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
assertEquals("435df2f061add3589cb326cc64be9b9c3897ceca", c2.getId()); assertEquals("435df2f061add3589cb326cc64be9b9c3897ceca", c2.getId());
} }
@Test
public void testGetAncestor()
{
LogCommandRequest request = new LogCommandRequest();
request.setBranch("test-branch");
request.setAncestorChangeset("master");
ChangesetPagingResult result = createCommand().getChangesets(request);
assertNotNull(result);
assertEquals(1, result.getTotal());
assertEquals(1, result.getChangesets().size());
Changeset c = result.getChangesets().get(0);
assertNotNull(c);
assertEquals("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", c.getId());
}
@Test @Test
public void shouldFindDefaultBranchFromHEAD() throws Exception { public void shouldFindDefaultBranchFromHEAD() throws Exception {
setRepositoryHeadReference("ref: refs/heads/test-branch"); setRepositoryHeadReference("ref: refs/heads/test-branch");

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.19": "@scm-manager/ui-bundler@^0.0.21":
version "0.0.19" version "0.0.21"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.19.tgz#646ab1fa1e5389fad614542215c60678fb9816ae" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647"
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"
@@ -747,9 +747,9 @@
vinyl-source-stream "^2.0.0" vinyl-source-stream "^2.0.0"
watchify "^3.11.0" watchify "^3.11.0"
"@scm-manager/ui-extensions@^0.0.7": "@scm-manager/ui-extensions@^0.1.1":
version "0.0.7" version "0.1.1"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.0.7.tgz#a0a657a1410b78838ba0b36096ef631dca7fe27e" resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.1.tgz#966e62d89981e92a14adf7e674e646e76de96d45"
dependencies: dependencies:
react "^16.4.2" react "^16.4.2"
react-dom "^16.4.2" react-dom "^16.4.2"

View File

@@ -6,9 +6,9 @@
"build": "ui-bundler plugin" "build": "ui-bundler plugin"
}, },
"dependencies": { "dependencies": {
"@scm-manager/ui-extensions": "^0.0.7" "@scm-manager/ui-extensions": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.19" "@scm-manager/ui-bundler": "^0.0.21"
} }
} }

View File

@@ -0,0 +1,113 @@
//@flow
import React from "react";
import type { Links } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import { InputField, Checkbox } from "@scm-manager/ui-components";
type Configuration = {
"hgBinary": string,
"pythonBinary": string,
"pythonPath"?: string,
"repositoryDirectory": string,
"encoding": string,
"useOptimizedBytecode": boolean,
"showRevisionInId": boolean,
"disabled": boolean,
"_links": Links
};
type Props = {
initialConfiguration: Configuration,
readOnly: boolean,
onConfigurationChange: (Configuration, boolean) => void,
// context props
t: (string) => string
}
type State = Configuration & {
validationErrors: string[]
};
class HgConfigurationForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { ...props.initialConfiguration, validationErrors: [] };
}
updateValidationStatus = () => {
const requiredFields = [
"hgBinary", "pythonBinary", "repositoryDirectory", "encoding"
];
const validationErrors = [];
for (let field of requiredFields) {
if (!this.state[field]) {
validationErrors.push( field );
}
}
this.setState({
validationErrors
});
return validationErrors.length === 0;
};
hasValidationError = (name: string) => {
return this.state.validationErrors.indexOf(name) >= 0;
};
handleChange = (value: any, name: string) => {
this.setState({
[name]: value
}, () => this.props.onConfigurationChange(this.state, this.updateValidationStatus()));
};
inputField = (name: string) => {
const { readOnly, t } = this.props;
return <InputField
name={ name }
label={t("scm-hg-plugin.config." + name)}
helpText={t("scm-hg-plugin.config." + name + "HelpText")}
value={this.state[name]}
onChange={this.handleChange}
validationError={this.hasValidationError(name)}
errorMessage={t("scm-hg-plugin.config.required")}
disabled={readOnly}
/>;
};
checkbox = (name: string) => {
const { readOnly, t } = this.props;
return <Checkbox
name={ name }
label={t("scm-hg-plugin.config." + name)}
helpText={t("scm-hg-plugin.config." + name + "HelpText")}
checked={this.state[name]}
onChange={this.handleChange}
disabled={readOnly}
/>;
};
render() {
return (
<>
{this.inputField("hgBinary")}
{this.inputField("pythonBinary")}
{this.inputField("pythonPath")}
{this.inputField("repositoryDirectory")}
{this.inputField("encoding")}
{this.checkbox("useOptimizedBytecode")}
{this.checkbox("showRevisionInId")}
{this.checkbox("disabled")}
</>
);
}
}
export default translate("plugins")(HgConfigurationForm);

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import { Title, GlobalConfiguration } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import HgConfigurationForm from "./HgConfigurationForm";
type Props = {
link: string,
// context props
t: (string) => string
}
class HgGlobalConfiguration extends React.Component<Props> {
render() {
const { link, t } = this.props;
return (
<div>
<Title title={t("scm-hg-plugin.config.title")}/>
<GlobalConfiguration link={link} render={props => <HgConfigurationForm {...props} />}/>
</div>
);
}
}
export default translate("plugins")(HgGlobalConfiguration);

View File

@@ -2,6 +2,8 @@
import { binder } from "@scm-manager/ui-extensions"; import { binder } from "@scm-manager/ui-extensions";
import ProtocolInformation from "./ProtocolInformation"; import ProtocolInformation from "./ProtocolInformation";
import HgAvatar from "./HgAvatar"; import HgAvatar from "./HgAvatar";
import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components";
import HgGlobalConfiguration from "./HgGlobalConfiguration";
const hgPredicate = (props: Object) => { const hgPredicate = (props: Object) => {
return props.repository && props.repository.type === "hg"; return props.repository && props.repository.type === "hg";
@@ -9,3 +11,7 @@ const hgPredicate = (props: Object) => {
binder.bind("repos.repository-details.information", ProtocolInformation, hgPredicate); binder.bind("repos.repository-details.information", ProtocolInformation, hgPredicate);
binder.bind("repos.repository-avatar", HgAvatar, hgPredicate); binder.bind("repos.repository-avatar", HgAvatar, hgPredicate);
// bind global configuration
cfgBinder.bindGlobal("/hg", "scm-hg-plugin.config.link", "hgConfig", HgGlobalConfiguration);

View File

@@ -4,6 +4,27 @@
"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"
},
"config": {
"link": "Mercurial",
"title": "Mercurial Configuration",
"hgBinary": "HG Binary",
"hgBinaryHelpText": "Location of Mercurial binary.",
"pythonBinary": "Python Binary",
"pythonBinaryHelpText": "Location of Python binary.",
"pythonPath": "Python Module Search Path",
"pythonPathHelpText": "Python Module Search Path (PYTHONPATH).",
"repositoryDirectory": "Repository directory",
"repositoryDirectoryHelpText": "Location of Mercurial repositories.",
"encoding": "Encoding",
"encodingHelpText": "Repository Encoding.",
"useOptimizedBytecode": "Optimized Bytecode (.pyo)",
"useOptimizedBytecodeHelpText": "Use the Python '-O' switch.",
"showRevisionInId": "Show Revision",
"showRevisionInIdHelpText": "Show revision as part of the node id.",
"disabled": "Disabled",
"disabledHelpText": "Enable or disable the Mercurial plugin.",
"required": "This configuration value is required"
} }
} }
} }

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.19": "@scm-manager/ui-bundler@^0.0.21":
version "0.0.19" version "0.0.21"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.19.tgz#646ab1fa1e5389fad614542215c60678fb9816ae" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647"
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"
@@ -660,7 +660,6 @@
browserify-css "^0.14.0" browserify-css "^0.14.0"
colors "^1.3.1" colors "^1.3.1"
commander "^2.17.1" commander "^2.17.1"
connect-history-api-fallback "^1.5.0"
eslint "^5.4.0" eslint "^5.4.0"
eslint-config-react-app "^2.1.0" eslint-config-react-app "^2.1.0"
eslint-plugin-flowtype "^2.50.0" eslint-plugin-flowtype "^2.50.0"
@@ -682,9 +681,9 @@
vinyl-source-stream "^2.0.0" vinyl-source-stream "^2.0.0"
watchify "^3.11.0" watchify "^3.11.0"
"@scm-manager/ui-extensions@^0.0.7": "@scm-manager/ui-extensions@^0.1.1":
version "0.0.7" version "0.1.1"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.0.7.tgz#a0a657a1410b78838ba0b36096ef631dca7fe27e" resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.1.tgz#966e62d89981e92a14adf7e674e646e76de96d45"
dependencies: dependencies:
react "^16.4.2" react "^16.4.2"
react-dom "^16.4.2" react-dom "^16.4.2"

View File

@@ -6,9 +6,9 @@
"build": "ui-bundler plugin" "build": "ui-bundler plugin"
}, },
"dependencies": { "dependencies": {
"@scm-manager/ui-extensions": "^0.0.7" "@scm-manager/ui-extensions": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.19" "@scm-manager/ui-bundler": "^0.0.21"
} }
} }

View File

@@ -0,0 +1,107 @@
//@flow
import React from "react";
import type { Links } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import { InputField, Checkbox, Select } from "@scm-manager/ui-components";
type Configuration = {
repositoryDirectory: string,
compatibility: string,
enabledGZip: boolean,
disabled: boolean,
_links: Links
};
type Props = {
initialConfiguration: Configuration,
readOnly: boolean,
onConfigurationChange: (Configuration, boolean) => void,
// context props
t: (string) => string
}
type State = Configuration;
class HgConfigurationForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { ...props.initialConfiguration, validationErrors: [] };
}
isValid = () => {
return !!this.state.repositoryDirectory;
};
handleChange = (value: any, name: string) => {
this.setState({
[name]: value
}, () => this.props.onConfigurationChange(this.state, this.isValid()));
};
compatibilityOptions = (values: string[]) => {
const options = [];
for (let value of values) {
options.push( this.compatibilityOption(value) );
}
return options;
};
compatibilityOption = (value: string) => {
return {
value,
label: this.props.t("scm-svn-plugin.config.compatibility-values." + value.toLowerCase())
};
};
render() {
const { readOnly, t } = this.props;
const compatibilityOptions = this.compatibilityOptions([
"NONE", "PRE14", "PRE15", "PRE16", "PRE17", "WITH17"
]);
return (
<>
<InputField
name="repositoryDirectory"
label={t("scm-svn-plugin.config.directory")}
helpText={t("scm-svn-plugin.config.directoryHelpText")}
value={this.state.repositoryDirectory}
errorMessage={t("scm-svn-plugin.config.required")}
validationError={!this.state.repositoryDirectory}
onChange={this.handleChange}
disabled={readOnly}
/>
<Select
name="compatibility"
label={t("scm-svn-plugin.config.compatibility")}
helpText={t("scm-svn-plugin.config.compatibilityHelpText")}
value={this.state.compatibility}
options={compatibilityOptions}
onChange={this.handleChange}
/>
<Checkbox
name="enabledGZip"
label={t("scm-svn-plugin.config.enabledGZip")}
helpText={t("scm-svn-plugin.config.enabledGZipHelpText")}
checked={this.state.enabledGZip}
onChange={this.handleChange}
disabled={readOnly}
/>
<Checkbox
name="disabled"
label={t("scm-svn-plugin.config.disabled")}
helpText={t("scm-svn-plugin.config.disabledHelpText")}
checked={this.state.disabled}
onChange={this.handleChange}
disabled={readOnly}
/>
</>
);
}
}
export default translate("plugins")(HgConfigurationForm);

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { Title, GlobalConfiguration } from "@scm-manager/ui-components";
import SvnConfigurationForm from "./SvnConfigurationForm";
type Props = {
link: string,
// context props
t: (string) => string
}
class SvnGlobalConfiguration extends React.Component<Props> {
render() {
const { link, t } = this.props;
return (
<div>
<Title title={t("scm-svn-plugin.config.title")}/>
<GlobalConfiguration link={link} render={props => <SvnConfigurationForm {...props} />}/>
</div>
);
}
}
export default translate("plugins")(SvnGlobalConfiguration);

View File

@@ -1,7 +1,9 @@
// @flow // @flow
import { binder } from "@scm-manager/ui-extensions"; import { binder } from "@scm-manager/ui-extensions";
import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components";
import ProtocolInformation from "./ProtocolInformation"; import ProtocolInformation from "./ProtocolInformation";
import SvnAvatar from "./SvnAvatar"; import SvnAvatar from "./SvnAvatar";
import SvnGlobalConfiguration from "./SvnGlobalConfiguration";
const svnPredicate = (props: Object) => { const svnPredicate = (props: Object) => {
return props.repository && props.repository.type === "svn"; return props.repository && props.repository.type === "svn";
@@ -9,3 +11,7 @@ const svnPredicate = (props: Object) => {
binder.bind("repos.repository-details.information", ProtocolInformation, svnPredicate); binder.bind("repos.repository-details.information", ProtocolInformation, svnPredicate);
binder.bind("repos.repository-avatar", SvnAvatar, svnPredicate); binder.bind("repos.repository-avatar", SvnAvatar, svnPredicate);
// bind global configuration
cfgBinder.bindGlobal("/svn", "scm-svn-plugin.config.link", "svnConfig", SvnGlobalConfiguration);

View File

@@ -2,6 +2,27 @@
"scm-svn-plugin": { "scm-svn-plugin": {
"information": { "information": {
"checkout" : "Checkout repository" "checkout" : "Checkout repository"
},
"config": {
"link": "Subversion",
"title": "Subversion Configuration",
"directory": "Repository Directory",
"directoryHelpText": "Location of Subversion repositories.",
"compatibility": "Version Compatibility",
"compatibilityHelpText": "Specifies with which subversion version repositories are compatible.",
"compatibility-values": {
"none": "No compatibility",
"pre14": "Pre 1.4 Compatible",
"pre15": "Pre 1.5 Compatible",
"pre16": "Pre 1.6 Compatible",
"pre17": "Pre 1.7 Compatible",
"with17": "With 1.7 Compatible"
},
"enabledGZip": "Enable GZip Compression",
"enabledGZipHelpText": "Enable GZip compression for svn responses.",
"disabled": "Disabled",
"disabledHelpText": "Enable or disable the Git plugin",
"required": "This configuration value is required"
} }
} }
} }

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.19": "@scm-manager/ui-bundler@^0.0.21":
version "0.0.19" version "0.0.21"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.19.tgz#646ab1fa1e5389fad614542215c60678fb9816ae" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647"
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"
@@ -681,9 +681,9 @@
vinyl-source-stream "^2.0.0" vinyl-source-stream "^2.0.0"
watchify "^3.11.0" watchify "^3.11.0"
"@scm-manager/ui-extensions@^0.0.7": "@scm-manager/ui-extensions@^0.1.1":
version "0.0.7" version "0.1.1"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.0.7.tgz#a0a657a1410b78838ba0b36096ef631dca7fe27e" resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.1.tgz#966e62d89981e92a14adf7e674e646e76de96d45"
dependencies: dependencies:
react "^16.4.2" react "^16.4.2"
react-dom "^16.4.2" react-dom "^16.4.2"

View File

@@ -3,16 +3,18 @@
"version": "0.0.1", "version": "0.0.1",
"description": "UI Components for SCM-Manager and its plugins", "description": "UI Components for SCM-Manager and its plugins",
"main": "src/index.js", "main": "src/index.js",
"files": ["src"], "files": [
"src"
],
"repository": "https://bitbucket.org/sdorra/scm-manager", "repository": "https://bitbucket.org/sdorra/scm-manager",
"author": "Sebastian Sdorra <sebastian.sdorra@cloudogu.com>", "author": "Sebastian Sdorra <sebastian.sdorra@cloudogu.com>",
"license" : "BSD-3-Clause", "license": "BSD-3-Clause",
"scripts": { "scripts": {
"update-index": "create-index -r src", "update-index": "create-index -r src",
"eslint-fix": "eslint src --fix" "eslint-fix": "eslint src --fix"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.19", "@scm-manager/ui-bundler": "^0.0.21",
"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",
@@ -23,23 +25,32 @@
"react-router-enzyme-context": "^1.2.0" "react-router-enzyme-context": "^1.2.0"
}, },
"dependencies": { "dependencies": {
"@scm-manager/ui-extensions": "^0.1.1",
"@scm-manager/ui-types": "0.0.1",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"moment": "^2.22.2", "moment": "^2.22.2",
"react": "^16.5.2", "react": "^16.5.2",
"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"
"@scm-manager/ui-types": "0.0.1"
}, },
"browserify": { "browserify": {
"transform": [ "transform": [
["browserify-css"], [
"browserify-css"
],
[ [
"babelify", "babelify",
{ {
"plugins": ["@babel/plugin-proposal-class-properties"], "plugins": [
"presets": ["@babel/preset-env", "@babel/preset-flow", "@babel/preset-react"] "@babel/plugin-proposal-class-properties"
],
"presets": [
"@babel/preset-env",
"@babel/preset-flow",
"@babel/preset-react"
]
} }
] ]
] ]

View File

@@ -0,0 +1,43 @@
// @flow
import React from "react";
import { binder } from "@scm-manager/ui-extensions";
import { NavLink } from "../navigation";
import { Route } from "react-router-dom";
import { translate } from "react-i18next";
class ConfigurationBinder {
i18nNamespace: string = "plugins";
bindGlobal(to: string, labelI18nKey: string, linkName: string, ConfigurationComponent: any) {
// create predicate based on the link name of the index resource
// if the linkname is not available, the navigation link and the route are not bound to the extension points
const configPredicate = (props: Object) => {
return props.links && props.links[linkName];
};
// create NavigationLink with translated label
const ConfigNavLink = translate(this.i18nNamespace)(({t}) => {
return <NavLink to={"/config" + to} label={t(labelI18nKey)} />;
});
// bind navigation link to extension point
binder.bind("config.navigation", ConfigNavLink, configPredicate);
// route for global configuration, passes the link from the index resource to component
const ConfigRoute = ({ url, links }) => {
const link = links[linkName].href;
return <Route path={url + to}
render={() => <ConfigurationComponent link={link}/>}
exact/>;
};
// bind config route to extension point
binder.bind("config.route", ConfigRoute, configPredicate);
}
}
export default new ConfigurationBinder();

View File

@@ -0,0 +1,162 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { Links } from "@scm-manager/ui-types";
import {
apiClient,
SubmitButton,
Loading,
ErrorNotification
} from "../";
type RenderProps = {
readOnly: boolean,
initialConfiguration: Configuration,
onConfigurationChange: (Configuration, boolean) => void
};
type Props = {
link: string,
render: (props: RenderProps) => any, // ???
// context props
t: (string) => string
};
type Configuration = {
_links: Links
} & Object;
type State = {
error?: Error,
fetching: boolean,
modifying: boolean,
contentType?: string,
configuration?: Configuration,
modifiedConfiguration?: Configuration,
valid: boolean
};
/**
* GlobalConfiguration uses the render prop pattern to encapsulate the logic for
* synchronizing the configuration with the backend.
*/
class GlobalConfiguration extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
fetching: true,
modifying: false,
valid: false
};
}
componentDidMount() {
const { link } = this.props;
apiClient.get(link)
.then(this.captureContentType)
.then(response => response.json())
.then(this.loadConfig)
.catch(this.handleError);
}
captureContentType = (response: Response) => {
const contentType = response.headers.get("Content-Type");
this.setState({
contentType
});
return response;
};
getContentType = (): string => {
const { contentType } = this.state;
return contentType ? contentType : "application/json";
};
handleError = (error: Error) => {
this.setState({
error,
fetching: false,
modifying: false
});
};
loadConfig = (configuration: Configuration) => {
this.setState({
configuration,
fetching: false,
error: undefined
});
};
getModificationUrl = (): ?string => {
const { configuration } = this.state;
if (configuration) {
const links = configuration._links;
if (links && links.update) {
return links.update.href;
}
}
};
isReadOnly = (): boolean => {
const modificationUrl = this.getModificationUrl();
return !modificationUrl;
};
configurationChanged = (configuration: Configuration, valid: boolean) => {
this.setState({
modifiedConfiguration: configuration,
valid
});
};
modifyConfiguration = (event: Event) => {
event.preventDefault();
this.setState({ modifying: true });
const {modifiedConfiguration} = this.state;
apiClient.put(this.getModificationUrl(), modifiedConfiguration, this.getContentType())
.then(() => this.setState({ modifying: false }))
.catch(this.handleError);
};
render() {
const { t } = this.props;
const { fetching, error, configuration, modifying, valid } = this.state;
if (error) {
return <ErrorNotification error={error}/>;
} else if (fetching || !configuration) {
return <Loading />;
} else {
const readOnly = this.isReadOnly();
const renderProps: RenderProps = {
readOnly,
initialConfiguration: configuration,
onConfigurationChange: this.configurationChanged
};
return (
<form onSubmit={this.modifyConfiguration}>
{ this.props.render(renderProps) }
<hr/>
<SubmitButton
label={t("config-form.submit")}
disabled={!valid || readOnly}
loading={modifying}
/>
</form>
);
}
}
}
export default translate("config")(GlobalConfiguration);

View File

@@ -0,0 +1,3 @@
// @flow
export { default as ConfigurationBinder } from "./ConfigurationBinder";
export { default as GlobalConfiguration } from "./GlobalConfiguration";

View File

@@ -4,15 +4,16 @@ import { Help } from "../index";
type Props = { type Props = {
label?: string, label?: string,
name?: string,
checked: boolean, checked: boolean,
onChange?: boolean => void, onChange?: (value: boolean, name?: string) => void,
disabled?: boolean, disabled?: boolean,
helpText?: string helpText?: string
}; };
class Checkbox extends React.Component<Props> { class Checkbox extends React.Component<Props> {
onCheckboxChange = (event: SyntheticInputEvent<HTMLInputElement>) => { onCheckboxChange = (event: SyntheticInputEvent<HTMLInputElement>) => {
if (this.props.onChange) { if (this.props.onChange) {
this.props.onChange(event.target.checked); this.props.onChange(event.target.checked, this.props.name);
} }
}; };

View File

@@ -5,11 +5,12 @@ import { LabelWithHelpIcon } from "../index";
type Props = { type Props = {
label?: string, label?: string,
name?: string,
placeholder?: string, placeholder?: string,
value?: string, value?: string,
type?: string, type?: string,
autofocus?: boolean, autofocus?: boolean,
onChange: string => void, onChange: (value: string, name?: string) => void,
onReturnPressed?: () => void, onReturnPressed?: () => void,
validationError: boolean, validationError: boolean,
errorMessage: string, errorMessage: string,
@@ -32,7 +33,7 @@ class InputField extends React.Component<Props> {
} }
handleInput = (event: SyntheticInputEvent<HTMLInputElement>) => { handleInput = (event: SyntheticInputEvent<HTMLInputElement>) => {
this.props.onChange(event.target.value); this.props.onChange(event.target.value, this.props.name);
}; };
handleKeyPress = (event: SyntheticKeyboardEvent<HTMLInputElement>) => { handleKeyPress = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {

View File

@@ -9,10 +9,11 @@ export type SelectItem = {
}; };
type Props = { type Props = {
name?: string,
label?: string, label?: string,
options: SelectItem[], options: SelectItem[],
value?: SelectItem, value?: string,
onChange: string => void, onChange: (value: string, name?: string) => void,
loading?: boolean, loading?: boolean,
helpText?: string helpText?: string
}; };
@@ -29,7 +30,7 @@ class Select extends React.Component<Props> {
} }
handleInput = (event: SyntheticInputEvent<HTMLSelectElement>) => { handleInput = (event: SyntheticInputEvent<HTMLSelectElement>) => {
this.props.onChange(event.target.value); this.props.onChange(event.target.value, this.props.name);
}; };
render() { render() {

View File

@@ -24,6 +24,7 @@ export { getPageFromMatch } from "./urls";
export { apiClient, NOT_FOUND_ERROR, UNAUTHORIZED_ERROR } from "./apiclient.js"; export { apiClient, NOT_FOUND_ERROR, UNAUTHORIZED_ERROR } from "./apiclient.js";
export * from "./buttons"; export * from "./buttons";
export * from "./config";
export * from "./forms"; export * from "./forms";
export * from "./layout"; export * from "./layout";
export * from "./modals"; export * from "./modals";

View File

@@ -1,6 +1,7 @@
//@flow //@flow
import React from "react"; import React from "react";
import type { Me } from "@scm-manager/ui-types"; import type { Me } from "@scm-manager/ui-types";
import {Link} from "react-router-dom";
type Props = { type Props = {
me?: Me me?: Me
@@ -15,7 +16,7 @@ class Footer extends React.Component<Props> {
return ( return (
<footer className="footer"> <footer className="footer">
<div className="container is-centered"> <div className="container is-centered">
<p className="has-text-centered">{me.displayName}</p> <p className="has-text-centered"><Link to={"/me"}>{me.displayName}</Link></p>
</div> </div>
</footer> </footer>
); );

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.19": "@scm-manager/ui-bundler@^0.0.21":
version "0.0.19" version "0.0.21"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.19.tgz#646ab1fa1e5389fad614542215c60678fb9816ae" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647"
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"
@@ -681,6 +681,13 @@
vinyl-source-stream "^2.0.0" vinyl-source-stream "^2.0.0"
watchify "^3.11.0" watchify "^3.11.0"
"@scm-manager/ui-extensions@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-extensions/-/ui-extensions-0.1.1.tgz#966e62d89981e92a14adf7e674e646e76de96d45"
dependencies:
react "^16.4.2"
react-dom "^16.4.2"
"@types/node@*": "@types/node@*":
version "10.12.0" version "10.12.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.0.tgz#ea6dcbddbc5b584c83f06c60e82736d8fbb0c235" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.0.tgz#ea6dcbddbc5b584c83f06c60e82736d8fbb0c235"
@@ -6229,6 +6236,15 @@ rc@^1.2.7:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
react-dom@^16.4.2:
version "16.6.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.6.0.tgz#6375b8391e019a632a89a0988bce85f0cc87a92f"
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.10.0"
react-dom@^16.5.2: react-dom@^16.5.2:
version "16.5.2" version "16.5.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7"
@@ -6299,6 +6315,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@^16.4.2:
version "16.6.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.6.0.tgz#b34761cfaf3e30f5508bc732fb4736730b7da246"
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.10.0"
react@^16.5.2: react@^16.5.2:
version "16.5.2" version "16.5.2"
resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42" resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42"
@@ -6726,6 +6751,13 @@ schedule@^0.5.0:
dependencies: dependencies:
object-assign "^4.1.1" object-assign "^4.1.1"
scheduler@^0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.10.0.tgz#7988de90fe7edccc774ea175a783e69c40c521e1"
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
"semver@2 || 3 || 4 || 5", semver@^5.0.1, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1: "semver@2 || 3 || 4 || 5", semver@^5.0.1, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1:
version "5.6.0" version "5.6.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"

View File

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

View File

@@ -2,5 +2,6 @@
export type Me = { export type Me = {
name: string, name: string,
displayName: string displayName: string,
mail: string
}; };

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.19": "@scm-manager/ui-bundler@^0.0.21":
version "0.0.19" version "0.0.21"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.19.tgz#646ab1fa1e5389fad614542215c60678fb9816ae" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.21.tgz#f8b5fa355415cc67b8aaf8744e1701a299dff647"
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

@@ -6,7 +6,7 @@
"main": "src/index.js", "main": "src/index.js",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^5.3.1", "@fortawesome/fontawesome-free": "^5.3.1",
"@scm-manager/ui-extensions": "^0.0.7", "@scm-manager/ui-extensions": "^0.1.1",
"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",
@@ -48,7 +48,7 @@
"pre-commit": "jest && flow && eslint src" "pre-commit": "jest && flow && eslint src"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.19", "@scm-manager/ui-bundler": "^0.0.21",
"copyfiles": "^2.0.0", "copyfiles": "^2.0.0",
"enzyme": "^3.3.0", "enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1", "enzyme-adapter-react-16": "^1.1.1",

View File

@@ -38,5 +38,14 @@
"paginator": { "paginator": {
"next": "Next", "next": "Next",
"previous": "Previous" "previous": "Previous"
},
"profile": {
"actions-label": "Actions",
"username": "Username",
"displayName": "Display Name",
"mail": "E-Mail",
"change-password": "Change password",
"error-title": "Error",
"error-subtitle": "Cannot display profile"
} }
} }

View File

@@ -29,6 +29,9 @@
"edit-user-button": { "edit-user-button": {
"label": "Edit" "label": "Edit"
}, },
"set-password-button": {
"label": "Set password"
},
"user-form": { "user-form": {
"submit": "Submit" "submit": "Submit"
}, },
@@ -49,8 +52,11 @@
"name-invalid": "This name is invalid", "name-invalid": "This name is invalid",
"displayname-invalid": "This displayname is invalid", "displayname-invalid": "This displayname is invalid",
"password-invalid": "Password has to be between 6 and 32 characters", "password-invalid": "Password has to be between 6 and 32 characters",
"passwordValidation-invalid": "Passwords have to be the same", "passwordValidation-invalid": "Passwords have to be identical",
"validatePassword": "Please validate password here" "validatePassword": "Confirm password"
},
"password": {
"set-password-successful": "Password successfully set"
}, },
"help": { "help": {
"usernameHelpText": "Unique name of the user.", "usernameHelpText": "Unique name of the user.",

View File

@@ -2,12 +2,19 @@
import React from "react"; import React from "react";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import { Route } from "react-router"; import { Route } from "react-router";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Links } from "@scm-manager/ui-types";
import { Page, Navigation, NavLink, Section } from "@scm-manager/ui-components"; import { Page, Navigation, NavLink, Section } from "@scm-manager/ui-components";
import GlobalConfig from "./GlobalConfig"; import GlobalConfig from "./GlobalConfig";
import type { History } from "history"; import type { History } from "history";
import {connect} from "react-redux";
import {compose} from "redux";
import { getLinks } from "../../modules/indexResource";
type Props = { type Props = {
links: Links,
// context objects // context objects
t: string => string, t: string => string,
match: any, match: any,
@@ -27,15 +34,23 @@ class Config extends React.Component<Props> {
}; };
render() { render() {
const { t } = this.props; const { links, t } = this.props;
const url = this.matchedUrl(); const url = this.matchedUrl();
const extensionProps = {
links,
url
};
return ( return (
<Page> <Page>
<div className="columns"> <div className="columns">
<div className="column is-three-quarters"> <div className="column is-three-quarters">
<Route path={url} exact component={GlobalConfig} /> <Route path={url} exact component={GlobalConfig} />
<ExtensionPoint name="config.route"
props={extensionProps}
renderAll={true}
/>
</div> </div>
<div className="column"> <div className="column">
<Navigation> <Navigation>
@@ -44,6 +59,10 @@ class Config extends React.Component<Props> {
to={`${url}`} to={`${url}`}
label={t("global-config.navigation-label")} label={t("global-config.navigation-label")}
/> />
<ExtensionPoint name="config.navigation"
props={extensionProps}
renderAll={true}
/>
</Section> </Section>
</Navigation> </Navigation>
</div> </div>
@@ -53,4 +72,15 @@ class Config extends React.Component<Props> {
} }
} }
export default translate("config")(Config); const mapStateToProps = (state: any) => {
const links = getLinks(state);
return {
links
};
};
export default compose(
connect(mapStateToProps),
translate("config")
)(Config);

View File

@@ -19,6 +19,7 @@ import SingleGroup from "../groups/containers/SingleGroup";
import AddGroup from "../groups/containers/AddGroup"; import AddGroup from "../groups/containers/AddGroup";
import Config from "../config/containers/Config"; import Config from "../config/containers/Config";
import Profile from "./Profile";
type Props = { type Props = {
authenticated?: boolean authenticated?: boolean
@@ -101,11 +102,16 @@ class Main extends React.Component<Props> {
authenticated={authenticated} authenticated={authenticated}
/> />
<ProtectedRoute <ProtectedRoute
exact
path="/config" path="/config"
component={Config} component={Config}
authenticated={authenticated} authenticated={authenticated}
/> />
<ProtectedRoute
exact
path="/me"
component={Profile}
authenticated={authenticated}
/>
</Switch> </Switch>
</div> </div>
); );

View File

@@ -0,0 +1,98 @@
// @flow
import React from "react";
import {
Page,
Navigation,
Section,
MailLink
} from "../../../scm-ui-components/packages/ui-components/src/index";
import { NavLink } from "react-router-dom";
import { getMe } from "../modules/auth";
import { compose } from "redux";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import type { Me } from "../../../scm-ui-components/packages/ui-types/src/index";
import AvatarWrapper from "../repos/components/changesets/AvatarWrapper";
import { ErrorPage } from "@scm-manager/ui-components";
type Props = {
me: Me,
// Context props
t: string => string
};
type State = {};
class Profile extends React.Component<Props, State> {
render() {
const { me, t } = this.props;
if (me) {
return (
<ErrorPage
title={t("profile.error-title")}
subtitle={t("profile.error-subtitle")}
error={{ name: "Error", message: "'me' is undefined" }}
/>
);
}
return (
<Page title={me.displayName}>
<div className="columns">
<AvatarWrapper>
<div>
<figure className="media-left">
<p className="image is-64x64">
{
// TODO: add avatar
}
</p>
</figure>
</div>
</AvatarWrapper>
<div className="column is-two-quarters">
<table className="table">
<tbody>
<tr>
<td>{t("profile.username")}</td>
<td>{me.name}</td>
</tr>
<tr>
<td>{t("profile.displayName")}</td>
<td>{me.displayName}</td>
</tr>
<tr>
<td>{t("profile.mail")}</td>
<td>
<MailLink address={me.mail} />
</td>
</tr>
</tbody>
</table>
</div>
<div className="column is-one-quarter">
<Navigation>
<Section label={t("profile.actions-label")} />
<NavLink to={"me/password"}>
{t("profile.change-password")}
</NavLink>
</Navigation>
</div>
</div>
</Page>
);
}
}
const mapStateToProps = state => {
return {
me: getMe(state)
};
};
export default compose(
translate("commons"),
connect(mapStateToProps)
)(Profile);

View File

@@ -2,16 +2,21 @@
import React from "react"; 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 { modifyGroup, fetchGroup } from "../modules/groups"; import {
modifyGroup,
modifyGroupReset,
isModifyGroupPending,
getModifyGroupFailure
} 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 { isModifyGroupPending, getModifyGroupFailure } from "../modules/groups";
import { ErrorNotification } from "@scm-manager/ui-components"; import { ErrorNotification } from "@scm-manager/ui-components";
type Props = { type Props = {
group: Group, group: Group,
modifyGroup: (group: Group, callback?: () => void) => void, modifyGroup: (group: Group, callback?: () => void) => void,
modifyGroupReset: Group => void,
fetchGroup: (name: string) => void, fetchGroup: (name: string) => void,
history: History, history: History,
loading?: boolean, loading?: boolean,
@@ -19,8 +24,12 @@ type Props = {
}; };
class EditGroup extends React.Component<Props> { class EditGroup extends React.Component<Props> {
componentDidMount() {
const { group, modifyGroupReset } = this.props;
modifyGroupReset(group);
}
groupModified = (group: Group) => () => { groupModified = (group: Group) => () => {
this.props.fetchGroup(group.name);
this.props.history.push(`/group/${group.name}`); this.props.history.push(`/group/${group.name}`);
}; };
@@ -59,8 +68,8 @@ const mapDispatchToProps = dispatch => {
modifyGroup: (group: Group, callback?: () => void) => { modifyGroup: (group: Group, callback?: () => void) => {
dispatch(modifyGroup(group, callback)); dispatch(modifyGroup(group, callback));
}, },
fetchGroup: (name: string) => { modifyGroupReset: (group: Group) => {
dispatch(fetchGroup(name)); dispatch(modifyGroupReset(group));
} }
}; };
}; };

View File

@@ -16,7 +16,7 @@ import type { Group } from "@scm-manager/ui-types";
import type { History } from "history"; import type { History } from "history";
import { import {
deleteGroup, deleteGroup,
fetchGroup, fetchGroupByName,
getGroupByName, getGroupByName,
isFetchGroupPending, isFetchGroupPending,
getFetchGroupFailure, getFetchGroupFailure,
@@ -37,7 +37,7 @@ type Props = {
// dispatcher functions // dispatcher functions
deleteGroup: (group: Group, callback?: () => void) => void, deleteGroup: (group: Group, callback?: () => void) => void,
fetchGroup: (string, string) => void, fetchGroupByName: (string, string) => void,
// context objects // context objects
t: string => string, t: string => string,
@@ -47,7 +47,7 @@ type Props = {
class SingleGroup extends React.Component<Props> { class SingleGroup extends React.Component<Props> {
componentDidMount() { componentDidMount() {
this.props.fetchGroup(this.props.groupLink, this.props.name); this.props.fetchGroupByName(this.props.groupLink, this.props.name);
} }
stripEndingSlash = (url: string) => { stripEndingSlash = (url: string) => {
@@ -147,8 +147,8 @@ const mapStateToProps = (state, ownProps) => {
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchGroup: (link: string, name: string) => { fetchGroupByName: (link: string, name: string) => {
dispatch(fetchGroup(link, name)); dispatch(fetchGroupByName(link, name));
}, },
deleteGroup: (group: Group, callback?: () => void) => { deleteGroup: (group: Group, callback?: () => void) => {
dispatch(deleteGroup(group, callback)); dispatch(deleteGroup(group, callback));

View File

@@ -26,6 +26,7 @@ export const MODIFY_GROUP = "scm/groups/MODIFY_GROUP";
export const MODIFY_GROUP_PENDING = `${MODIFY_GROUP}_${types.PENDING_SUFFIX}`; export const MODIFY_GROUP_PENDING = `${MODIFY_GROUP}_${types.PENDING_SUFFIX}`;
export const MODIFY_GROUP_SUCCESS = `${MODIFY_GROUP}_${types.SUCCESS_SUFFIX}`; export const MODIFY_GROUP_SUCCESS = `${MODIFY_GROUP}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_GROUP_FAILURE = `${MODIFY_GROUP}_${types.FAILURE_SUFFIX}`; export const MODIFY_GROUP_FAILURE = `${MODIFY_GROUP}_${types.FAILURE_SUFFIX}`;
export const MODIFY_GROUP_RESET = `${MODIFY_GROUP}_${types.RESET_SUFFIX}`;
export const DELETE_GROUP = "scm/groups/DELETE"; export const DELETE_GROUP = "scm/groups/DELETE";
export const DELETE_GROUP_PENDING = `${DELETE_GROUP}_${types.PENDING_SUFFIX}`; export const DELETE_GROUP_PENDING = `${DELETE_GROUP}_${types.PENDING_SUFFIX}`;
@@ -84,12 +85,20 @@ export function fetchGroupsFailure(url: string, error: Error): Action {
} }
//fetch group //fetch group
export function fetchGroup(link: string, name: string) { export function fetchGroupByLink(group: Group) {
return fetchGroup(group._links.self.href, group.name);
}
export function fetchGroupByName(link: string, name: string) {
const groupUrl = link.endsWith("/") ? link + name : link + "/" + name; const groupUrl = link.endsWith("/") ? link + name : link + "/" + name;
return fetchGroup(groupUrl, name);
}
function fetchGroup(link: string, name: string) {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(fetchGroupPending(name)); dispatch(fetchGroupPending(name));
return apiClient return apiClient
.get(groupUrl) .get(link)
.then(response => { .then(response => {
return response.json(); return response.json();
}) })
@@ -189,6 +198,9 @@ export function modifyGroup(group: Group, callback?: () => void) {
callback(); callback();
} }
}) })
.then(() => {
dispatch(fetchGroupByLink(group));
})
.catch(cause => { .catch(cause => {
dispatch( dispatch(
modifyGroupFailure( modifyGroupFailure(
@@ -227,6 +239,13 @@ export function modifyGroupFailure(group: Group, error: Error): Action {
}; };
} }
export function modifyGroupReset(group: Group): Action {
return {
type: MODIFY_GROUP_RESET,
itemId: group.name
};
}
//delete group //delete group
export function deleteGroup(group: Group, callback?: () => void) { export function deleteGroup(group: Group, callback?: () => void) {
@@ -361,8 +380,6 @@ function byNamesReducer(state: any = {}, action: any = {}) {
}; };
case FETCH_GROUP_SUCCESS: case FETCH_GROUP_SUCCESS:
return reducerByName(state, action.payload.name, action.payload); return reducerByName(state, action.payload.name, action.payload);
case MODIFY_GROUP_SUCCESS:
return reducerByName(state, action.payload.name, action.payload);
case DELETE_GROUP_SUCCESS: case DELETE_GROUP_SUCCESS:
const newGroupByNames = deleteGroupInGroupsByNames( const newGroupByNames = deleteGroupInGroupsByNames(
state, state,

View File

@@ -15,7 +15,8 @@ import reducer, {
getFetchGroupsFailure, getFetchGroupsFailure,
isFetchGroupsPending, isFetchGroupsPending,
selectListAsCollection, selectListAsCollection,
fetchGroup, fetchGroupByLink,
fetchGroupByName,
FETCH_GROUP_PENDING, FETCH_GROUP_PENDING,
FETCH_GROUP_SUCCESS, FETCH_GROUP_SUCCESS,
FETCH_GROUP_FAILURE, FETCH_GROUP_FAILURE,
@@ -46,6 +47,7 @@ import reducer, {
getCreateGroupLink getCreateGroupLink
} from "./groups"; } from "./groups";
const GROUPS_URL = "/api/v2/groups"; const GROUPS_URL = "/api/v2/groups";
const URL_HUMAN_GROUP = "http://localhost:8081/api/v2/groups/humanGroup";
const URL = "/groups"; const URL = "/groups";
const error = new Error("You have an error!"); const error = new Error("You have an error!");
@@ -59,13 +61,13 @@ const humanGroup = {
members: ["userZaphod"], members: ["userZaphod"],
_links: { _links: {
self: { self: {
href: "http://localhost:8081/api/v2/groups/humanGroup" href: URL_HUMAN_GROUP
}, },
delete: { delete: {
href: "http://localhost:8081/api/v2/groups/humanGroup" href: URL_HUMAN_GROUP
}, },
update: { update: {
href: "http://localhost:8081/api/v2/groups/humanGroup" href: URL_HUMAN_GROUP
} }
}, },
_embedded: { _embedded: {
@@ -171,11 +173,37 @@ describe("groups fetch()", () => {
}); });
}); });
it("should sucessfully fetch single group", () => { it("should sucessfully fetch single group by name", () => {
fetchMock.getOnce(GROUPS_URL + "/humanGroup", humanGroup); fetchMock.getOnce(GROUPS_URL + "/humanGroup", humanGroup);
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchGroup(URL, "humanGroup")).then(() => { return store.dispatch(fetchGroupByName(URL, "humanGroup")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS);
expect(actions[1].payload).toBeDefined();
});
});
it("should fail fetching single group by name on HTTP 500", () => {
fetchMock.getOnce(GROUPS_URL + "/humanGroup", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchGroupByName(URL, "humanGroup")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should sucessfully fetch single group", () => {
fetchMock.getOnce(URL_HUMAN_GROUP, humanGroup);
const store = mockStore({});
return store.dispatch(fetchGroupByLink(humanGroup)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING); expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS); expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS);
@@ -184,12 +212,12 @@ describe("groups fetch()", () => {
}); });
it("should fail fetching single group on HTTP 500", () => { it("should fail fetching single group on HTTP 500", () => {
fetchMock.getOnce(GROUPS_URL + "/humanGroup", { fetchMock.getOnce(URL_HUMAN_GROUP, {
status: 500 status: 500
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchGroup(URL, "humanGroup")).then(() => { return store.dispatch(fetchGroupByLink(humanGroup)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING); expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE); expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE);
@@ -244,9 +272,10 @@ describe("groups fetch()", () => {
}); });
it("should successfully modify group", () => { it("should successfully modify group", () => {
fetchMock.putOnce("http://localhost:8081/api/v2/groups/humanGroup", { fetchMock.putOnce(URL_HUMAN_GROUP, {
status: 204 status: 204
}); });
fetchMock.getOnce(URL_HUMAN_GROUP, humanGroup);
const store = mockStore({}); const store = mockStore({});
@@ -254,14 +283,16 @@ describe("groups fetch()", () => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING); expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS); expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS);
expect(actions[2].type).toEqual(FETCH_GROUP_PENDING);
expect(actions[1].payload).toEqual(humanGroup); expect(actions[1].payload).toEqual(humanGroup);
}); });
}); });
it("should call the callback after modifying group", () => { it("should call the callback after modifying group", () => {
fetchMock.putOnce("http://localhost:8081/api/v2/groups/humanGroup", { fetchMock.putOnce(URL_HUMAN_GROUP, {
status: 204 status: 204
}); });
fetchMock.getOnce(URL_HUMAN_GROUP, humanGroup);
let called = false; let called = false;
const callback = () => { const callback = () => {
@@ -273,12 +304,13 @@ describe("groups fetch()", () => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING); expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS); expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS);
expect(actions[2].type).toEqual(FETCH_GROUP_PENDING);
expect(called).toBe(true); expect(called).toBe(true);
}); });
}); });
it("should fail modifying group on HTTP 500", () => { it("should fail modifying group on HTTP 500", () => {
fetchMock.putOnce("http://localhost:8081/api/v2/groups/humanGroup", { fetchMock.putOnce(URL_HUMAN_GROUP, {
status: 500 status: 500
}); });
@@ -293,7 +325,7 @@ describe("groups fetch()", () => {
}); });
it("should delete successfully group humanGroup", () => { it("should delete successfully group humanGroup", () => {
fetchMock.deleteOnce("http://localhost:8081/api/v2/groups/humanGroup", { fetchMock.deleteOnce(URL_HUMAN_GROUP, {
status: 204 status: 204
}); });
@@ -308,7 +340,7 @@ describe("groups fetch()", () => {
}); });
it("should call the callback, after successful delete", () => { it("should call the callback, after successful delete", () => {
fetchMock.deleteOnce("http://localhost:8081/api/v2/groups/humanGroup", { fetchMock.deleteOnce(URL_HUMAN_GROUP, {
status: 204 status: 204
}); });
@@ -324,7 +356,7 @@ describe("groups fetch()", () => {
}); });
it("should fail to delete group humanGroup", () => { it("should fail to delete group humanGroup", () => {
fetchMock.deleteOnce("http://localhost:8081/api/v2/groups/humanGroup", { fetchMock.deleteOnce(URL_HUMAN_GROUP, {
status: 500 status: 500
}); });

View File

@@ -1,5 +1,5 @@
// @flow // @flow
import type { Me } from "@scm-manager/ui-components"; import type { Me } from "@scm-manager/ui-types";
import * as types from "./types"; import * as types from "./types";
import { apiClient, UNAUTHORIZED_ERROR } from "@scm-manager/ui-components"; import { apiClient, UNAUTHORIZED_ERROR } from "@scm-manager/ui-components";
@@ -136,7 +136,11 @@ const callFetchMe = (link: string): Promise<Me> => {
return response.json(); return response.json();
}) })
.then(json => { .then(json => {
return { name: json.name, displayName: json.displayName }; return {
name: json.name,
displayName: json.displayName,
mail: json.mail
};
}); });
}; };

View File

@@ -37,7 +37,11 @@ import {
FETCH_INDEXRESOURCES_SUCCESS FETCH_INDEXRESOURCES_SUCCESS
} from "./indexResource"; } from "./indexResource";
const me = { name: "tricia", displayName: "Tricia McMillian" }; const me = {
name: "tricia",
displayName: "Tricia McMillian",
mail: "trillian@heartofgold.universe"
};
describe("auth reducer", () => { describe("auth reducer", () => {
it("should set me and login on successful fetch of me", () => { it("should set me and login on successful fetch of me", () => {

View File

@@ -7,7 +7,8 @@ import type { Repository } from "@scm-manager/ui-types";
import { import {
modifyRepo, modifyRepo,
isModifyRepoPending, isModifyRepoPending,
getModifyRepoFailure getModifyRepoFailure,
modifyRepoReset
} from "../modules/repos"; } from "../modules/repos";
import { withRouter } from "react-router-dom"; import { withRouter } from "react-router-dom";
import type { History } from "history"; import type { History } from "history";
@@ -16,6 +17,7 @@ import { ErrorNotification } from "@scm-manager/ui-components";
type Props = { type Props = {
repository: Repository, repository: Repository,
modifyRepo: (Repository, () => void) => void, modifyRepo: (Repository, () => void) => void,
modifyRepoReset: Repository => void,
loading: boolean, loading: boolean,
error: Error, error: Error,
@@ -25,6 +27,10 @@ type Props = {
}; };
class Edit extends React.Component<Props> { class Edit extends React.Component<Props> {
componentDidMount() {
const { modifyRepoReset, repository } = this.props;
modifyRepoReset(repository);
}
repoModified = () => { repoModified = () => {
const { history, repository } = this.props; const { history, repository } = this.props;
history.push(`/repo/${repository.namespace}/${repository.name}`); history.push(`/repo/${repository.namespace}/${repository.name}`);
@@ -61,6 +67,9 @@ const mapDispatchToProps = dispatch => {
return { return {
modifyRepo: (repo: Repository, callback: () => void) => { modifyRepo: (repo: Repository, callback: () => void) => {
dispatch(modifyRepo(repo, callback)); dispatch(modifyRepo(repo, callback));
},
modifyRepoReset: (repo: Repository) => {
dispatch(modifyRepoReset(repo));
} }
}; };
}; };

View File

@@ -1,19 +1,32 @@
//@flow //@flow
import React from "react"; import React from "react";
import {deleteRepo, fetchRepo, getFetchRepoFailure, getRepository, isFetchRepoPending} from "../modules/repos"; import {
deleteRepo,
fetchRepoByName,
getFetchRepoFailure,
getRepository,
isFetchRepoPending
} from "../modules/repos";
import {connect} from "react-redux"; import { connect } from "react-redux";
import {Route, Switch} from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import type {Repository} from "@scm-manager/ui-types"; import type { Repository } from "@scm-manager/ui-types";
import {ErrorPage, Loading, Navigation, NavLink, Page, Section} from "@scm-manager/ui-components"; import {
import {translate} from "react-i18next"; ErrorPage,
Loading,
Navigation,
NavLink,
Page,
Section
} from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import RepositoryDetails from "../components/RepositoryDetails"; import RepositoryDetails from "../components/RepositoryDetails";
import DeleteNavAction from "../components/DeleteNavAction"; import DeleteNavAction from "../components/DeleteNavAction";
import Edit from "../containers/Edit"; import Edit from "../containers/Edit";
import Permissions from "../permissions/containers/Permissions"; import Permissions from "../permissions/containers/Permissions";
import type {History} from "history"; import type { History } from "history";
import EditNavLink from "../components/EditNavLink"; import EditNavLink from "../components/EditNavLink";
import BranchRoot from "./ChangesetsRoot"; import BranchRoot from "./ChangesetsRoot";
@@ -32,7 +45,7 @@ type Props = {
repoLink: string, repoLink: string,
// dispatch functions // dispatch functions
fetchRepo: (link: string, namespace: string, name: string) => void, fetchRepoByName: (link: string, namespace: string, name: string) => void,
deleteRepo: (repository: Repository, () => void) => void, deleteRepo: (repository: Repository, () => void) => void,
// context props // context props
@@ -43,9 +56,9 @@ type Props = {
class RepositoryRoot extends React.Component<Props> { class RepositoryRoot extends React.Component<Props> {
componentDidMount() { componentDidMount() {
const { fetchRepo, namespace, name, repoLink } = this.props; const { fetchRepoByName, namespace, name, repoLink } = this.props;
fetchRepo(repoLink, namespace, name); fetchRepoByName(repoLink, namespace, name);
} }
stripEndingSlash = (url: string) => { stripEndingSlash = (url: string) => {
@@ -209,8 +222,8 @@ const mapStateToProps = (state, ownProps) => {
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchRepo: (link: string, namespace: string, name: string) => { fetchRepoByName: (link: string, namespace: string, name: string) => {
dispatch(fetchRepo(link, namespace, name)); dispatch(fetchRepoByName(link, namespace, name));
}, },
deleteRepo: (repository: Repository, callback: () => void) => { deleteRepo: (repository: Repository, callback: () => void) => {
dispatch(deleteRepo(repository, callback)); dispatch(deleteRepo(repository, callback));

View File

@@ -29,6 +29,7 @@ export const MODIFY_REPO = "scm/repos/MODIFY_REPO";
export const MODIFY_REPO_PENDING = `${MODIFY_REPO}_${types.PENDING_SUFFIX}`; export const MODIFY_REPO_PENDING = `${MODIFY_REPO}_${types.PENDING_SUFFIX}`;
export const MODIFY_REPO_SUCCESS = `${MODIFY_REPO}_${types.SUCCESS_SUFFIX}`; export const MODIFY_REPO_SUCCESS = `${MODIFY_REPO}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_REPO_FAILURE = `${MODIFY_REPO}_${types.FAILURE_SUFFIX}`; export const MODIFY_REPO_FAILURE = `${MODIFY_REPO}_${types.FAILURE_SUFFIX}`;
export const MODIFY_REPO_RESET = `${MODIFY_REPO}_${types.RESET_SUFFIX}`;
export const DELETE_REPO = "scm/repos/DELETE_REPO"; export const DELETE_REPO = "scm/repos/DELETE_REPO";
export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`; export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`;
@@ -99,13 +100,20 @@ export function fetchReposFailure(err: Error): Action {
} }
// fetch repo // fetch repo
export function fetchRepoByLink(repo: Repository) {
return fetchRepo(repo._links.self.href, repo.namespace, repo.name);
}
export function fetchRepo(link: string, namespace: string, name: string) { export function fetchRepoByName(link: string, namespace: string, name: string) {
const repoUrl = link.endsWith("/") ? link : link + "/"; const repoUrl = link.endsWith("/") ? link : link + "/";
return fetchRepo(`${repoUrl}${namespace}/${name}`, namespace, name);
}
function fetchRepo(link: string, namespace: string, name: string) {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(fetchRepoPending(namespace, name)); dispatch(fetchRepoPending(namespace, name));
return apiClient return apiClient
.get(`${repoUrl}${namespace}/${name}`) .get(link)
.then(response => response.json()) .then(response => response.json())
.then(repository => { .then(repository => {
dispatch(fetchRepoSuccess(repository)); dispatch(fetchRepoSuccess(repository));
@@ -213,6 +221,9 @@ export function modifyRepo(repository: Repository, callback?: () => void) {
callback(); callback();
} }
}) })
.then(() => {
dispatch(fetchRepoByLink(repository));
})
.catch(cause => { .catch(cause => {
const error = new Error(`failed to modify repo: ${cause.message}`); const error = new Error(`failed to modify repo: ${cause.message}`);
dispatch(modifyRepoFailure(repository, error)); dispatch(modifyRepoFailure(repository, error));
@@ -247,6 +258,14 @@ export function modifyRepoFailure(
}; };
} }
export function modifyRepoReset(repository: Repository): Action {
return {
type: MODIFY_REPO_RESET,
payload: { repository },
itemId: createIdentifier(repository)
};
}
// delete // delete
export function deleteRepo(repository: Repository, callback?: () => void) { export function deleteRepo(repository: Repository, callback?: () => void) {
@@ -347,8 +366,6 @@ export default function reducer(
switch (action.type) { switch (action.type) {
case FETCH_REPOS_SUCCESS: case FETCH_REPOS_SUCCESS:
return normalizeByNamespaceAndName(action.payload); return normalizeByNamespaceAndName(action.payload);
case MODIFY_REPO_SUCCESS:
return reducerByNames(state, action.payload);
case FETCH_REPO_SUCCESS: case FETCH_REPO_SUCCESS:
return reducerByNames(state, action.payload); return reducerByNames(state, action.payload);
default: default:

View File

@@ -15,7 +15,8 @@ import reducer, {
fetchReposByLink, fetchReposByLink,
fetchReposByPage, fetchReposByPage,
FETCH_REPO, FETCH_REPO,
fetchRepo, fetchRepoByLink,
fetchRepoByName,
FETCH_REPO_PENDING, FETCH_REPO_PENDING,
FETCH_REPO_SUCCESS, FETCH_REPO_SUCCESS,
FETCH_REPO_FAILURE, FETCH_REPO_FAILURE,
@@ -45,7 +46,6 @@ import reducer, {
MODIFY_REPO, MODIFY_REPO,
isModifyRepoPending, isModifyRepoPending,
getModifyRepoFailure, getModifyRepoFailure,
modifyRepoSuccess,
getPermissionsLink getPermissionsLink
} from "./repos"; } from "./repos";
import type { Repository, RepositoryCollection } from "@scm-manager/ui-types"; import type { Repository, RepositoryCollection } from "@scm-manager/ui-types";
@@ -323,7 +323,7 @@ describe("repos fetch", () => {
}); });
}); });
it("should successfully fetch repo slarti/fjords", () => { it("should successfully fetch repo slarti/fjords by name", () => {
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords); fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords);
const expectedActions = [ const expectedActions = [
@@ -343,18 +343,66 @@ describe("repos fetch", () => {
]; ];
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchRepo(URL, "slarti", "fjords")).then(() => { return store.dispatch(fetchRepoByName(URL, "slarti", "fjords")).then(() => {
expect(store.getActions()).toEqual(expectedActions); expect(store.getActions()).toEqual(expectedActions);
}); });
}); });
it("should dispatch FETCH_REPO_FAILURE, it the request for slarti/fjords fails", () => { it("should dispatch FETCH_REPO_FAILURE, if the request for slarti/fjords by name fails", () => {
fetchMock.getOnce(REPOS_URL + "/slarti/fjords", { fetchMock.getOnce(REPOS_URL + "/slarti/fjords", {
status: 500 status: 500
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchRepo(URL, "slarti", "fjords")).then(() => { return store.dispatch(fetchRepoByName(URL, "slarti", "fjords")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_REPO_PENDING);
expect(actions[1].type).toEqual(FETCH_REPO_FAILURE);
expect(actions[1].payload.namespace).toBe("slarti");
expect(actions[1].payload.name).toBe("fjords");
expect(actions[1].payload.error).toBeDefined();
expect(actions[1].itemId).toBe("slarti/fjords");
});
});
it("should successfully fetch repo slarti/fjords", () => {
fetchMock.getOnce(
"http://localhost:8081/api/v2/repositories/slarti/fjords",
slartiFjords
);
const expectedActions = [
{
type: FETCH_REPO_PENDING,
payload: {
namespace: "slarti",
name: "fjords"
},
itemId: "slarti/fjords"
},
{
type: FETCH_REPO_SUCCESS,
payload: slartiFjords,
itemId: "slarti/fjords"
}
];
const store = mockStore({});
return store.dispatch(fetchRepoByLink(slartiFjords)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_REPO_FAILURE, it the request for slarti/fjords fails", () => {
fetchMock.getOnce(
"http://localhost:8081/api/v2/repositories/slarti/fjords",
{
status: 500
}
);
const store = mockStore({});
return store.dispatch(fetchRepoByLink(slartiFjords)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_REPO_PENDING); expect(actions[0].type).toEqual(FETCH_REPO_PENDING);
expect(actions[1].type).toEqual(FETCH_REPO_FAILURE); expect(actions[1].type).toEqual(FETCH_REPO_FAILURE);
@@ -485,6 +533,12 @@ describe("repos fetch", () => {
fetchMock.putOnce(slartiFjords._links.update.href, { fetchMock.putOnce(slartiFjords._links.update.href, {
status: 204 status: 204
}); });
fetchMock.getOnce(
"http://localhost:8081/api/v2/repositories/slarti/fjords",
{
status: 500
}
);
let editedFjords = { ...slartiFjords }; let editedFjords = { ...slartiFjords };
editedFjords.description = "coast of africa"; editedFjords.description = "coast of africa";
@@ -495,6 +549,7 @@ describe("repos fetch", () => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING); expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS); expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS);
expect(actions[2].type).toEqual(FETCH_REPO_PENDING);
}); });
}); });
@@ -502,6 +557,12 @@ describe("repos fetch", () => {
fetchMock.putOnce(slartiFjords._links.update.href, { fetchMock.putOnce(slartiFjords._links.update.href, {
status: 204 status: 204
}); });
fetchMock.getOnce(
"http://localhost:8081/api/v2/repositories/slarti/fjords",
{
status: 500
}
);
let editedFjords = { ...slartiFjords }; let editedFjords = { ...slartiFjords };
editedFjords.description = "coast of africa"; editedFjords.description = "coast of africa";
@@ -517,6 +578,7 @@ describe("repos fetch", () => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_REPO_PENDING); expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS); expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS);
expect(actions[2].type).toEqual(FETCH_REPO_PENDING);
expect(called).toBe(true); expect(called).toBe(true);
}); });
}); });
@@ -574,18 +636,6 @@ describe("repos reducer", () => {
const newState = reducer({}, fetchRepoSuccess(slartiFjords)); const newState = reducer({}, fetchRepoSuccess(slartiFjords));
expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords); expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords);
}); });
it("should update reposByNames", () => {
const oldState = {
byNames: {
"slarti/fjords": slartiFjords
}
};
let slartiFjordsEdited = { ...slartiFjords };
slartiFjordsEdited.description = "I bless the rains down in Africa";
const newState = reducer(oldState, modifyRepoSuccess(slartiFjordsEdited));
expect(newState.byNames["slarti/fjords"]).toEqual(slartiFjordsEdited);
});
}); });
describe("repos selectors", () => { describe("repos selectors", () => {

View File

@@ -0,0 +1,177 @@
// @flow
import React from "react";
import type { User } from "@scm-manager/ui-types";
import {
InputField,
SubmitButton,
Notification,
ErrorNotification
} from "@scm-manager/ui-components";
import * as userValidator from "./userValidation";
import { translate } from "react-i18next";
import { updatePassword } from "./updatePassword";
type Props = {
user: User,
t: string => string
};
type State = {
password: string,
loading: boolean,
passwordConfirmationError: boolean,
validatePasswordError: boolean,
validatePassword: string,
error?: Error,
passwordChanged: boolean
};
class SetUserPassword extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
password: "",
loading: false,
passwordConfirmationError: false,
validatePasswordError: false,
validatePassword: "",
passwordChanged: false
};
}
passwordIsValid = () => {
return !(
this.state.validatePasswordError || this.state.passwordConfirmationError
);
};
setLoadingState = () => {
this.setState({
...this.state,
loading: true
});
};
setErrorState = (error: Error) => {
this.setState({
...this.state,
error: error,
loading: false
});
};
setSuccessfulState = () => {
this.setState({
...this.state,
loading: false,
passwordChanged: true,
password: "",
validatePassword: "",
validatePasswordError: false,
passwordConfirmationError: false
});
};
submit = (event: Event) => {
event.preventDefault();
if (this.passwordIsValid()) {
const { user } = this.props;
const { password } = this.state;
this.setLoadingState();
updatePassword(user._links.password.href, password)
.then(result => {
if (result.error) {
this.setErrorState(result.error);
} else {
this.setSuccessfulState();
}
})
.catch(err => {});
}
};
render() {
const { t } = this.props;
const { loading, passwordChanged, error } = this.state;
let message = null;
if (passwordChanged) {
message = (
<Notification
type={"success"}
children={t("password.set-password-successful")}
onClose={() => this.onClose()}
/>
);
} else if (error) {
message = <ErrorNotification error={error} />;
}
return (
<form onSubmit={this.submit}>
{message}
<InputField
label={t("user.password")}
type="password"
onChange={this.handlePasswordChange}
value={this.state.password ? this.state.password : ""}
validationError={this.state.validatePasswordError}
errorMessage={t("validation.password-invalid")}
helpText={t("help.passwordHelpText")}
/>
<InputField
label={t("validation.validatePassword")}
type="password"
onChange={this.handlePasswordValidationChange}
value={this.state ? this.state.validatePassword : ""}
validationError={this.state.passwordConfirmationError}
errorMessage={t("validation.passwordValidation-invalid")}
helpText={t("help.passwordConfirmHelpText")}
/>
<SubmitButton
disabled={!this.passwordIsValid()}
loading={loading}
label={t("user-form.submit")}
/>
</form>
);
}
handlePasswordChange = (password: string) => {
const validatePasswordError = !this.checkPasswords(
password,
this.state.validatePassword
);
this.setState({
validatePasswordError: !userValidator.isPasswordValid(password),
passwordConfirmationError: validatePasswordError,
password: password
});
};
handlePasswordValidationChange = (validatePassword: string) => {
const passwordConfirmed = this.checkPasswords(
this.state.password,
validatePassword
);
this.setState({
validatePassword,
passwordConfirmationError: !passwordConfirmed
});
};
checkPasswords = (password1: string, password2: string) => {
return password1 === password2;
};
onClose = () => {
this.setState({
...this.state,
passwordChanged: false
});
};
}
export default translate("users")(SetUserPassword);

View File

@@ -22,7 +22,7 @@ type State = {
mailValidationError: boolean, mailValidationError: boolean,
nameValidationError: boolean, nameValidationError: boolean,
displayNameValidationError: boolean, displayNameValidationError: boolean,
passwordValidationError: boolean, passwordConfirmationError: boolean,
validatePasswordError: boolean, validatePasswordError: boolean,
validatePassword: string validatePassword: string
}; };
@@ -44,7 +44,7 @@ class UserForm extends React.Component<Props, State> {
mailValidationError: false, mailValidationError: false,
displayNameValidationError: false, displayNameValidationError: false,
nameValidationError: false, nameValidationError: false,
passwordValidationError: false, passwordConfirmationError: false,
validatePasswordError: false, validatePasswordError: false,
validatePassword: "" validatePassword: ""
}; };
@@ -70,7 +70,7 @@ class UserForm extends React.Component<Props, State> {
this.state.validatePasswordError || this.state.validatePasswordError ||
this.state.nameValidationError || this.state.nameValidationError ||
this.state.mailValidationError || this.state.mailValidationError ||
this.state.passwordValidationError || this.state.passwordConfirmationError ||
this.state.displayNameValidationError || this.state.displayNameValidationError ||
this.isFalsy(user.name) || this.isFalsy(user.name) ||
this.isFalsy(user.displayName) this.isFalsy(user.displayName)
@@ -89,6 +89,7 @@ class UserForm extends React.Component<Props, State> {
const user = this.state.user; const user = this.state.user;
let nameField = null; let nameField = null;
let passwordFields = null;
if (!this.props.user) { if (!this.props.user) {
nameField = ( nameField = (
<InputField <InputField
@@ -100,6 +101,28 @@ class UserForm extends React.Component<Props, State> {
helpText={t("help.usernameHelpText")} helpText={t("help.usernameHelpText")}
/> />
); );
passwordFields = (
<>
<InputField
label={t("user.password")}
type="password"
onChange={this.handlePasswordChange}
value={user ? user.password : ""}
validationError={this.state.validatePasswordError}
errorMessage={t("validation.password-invalid")}
helpText={t("help.passwordHelpText")}
/>
<InputField
label={t("validation.validatePassword")}
type="password"
onChange={this.handlePasswordValidationChange}
value={this.state ? this.state.validatePassword : ""}
validationError={this.state.passwordConfirmationError}
errorMessage={t("validation.passwordValidation-invalid")}
helpText={t("help.passwordConfirmHelpText")}
/>
</>
);
} }
return ( return (
<form onSubmit={this.submit}> <form onSubmit={this.submit}>
@@ -120,24 +143,7 @@ class UserForm extends React.Component<Props, State> {
errorMessage={t("validation.mail-invalid")} errorMessage={t("validation.mail-invalid")}
helpText={t("help.mailHelpText")} helpText={t("help.mailHelpText")}
/> />
<InputField {passwordFields}
label={t("user.password")}
type="password"
onChange={this.handlePasswordChange}
value={user ? user.password : ""}
validationError={this.state.validatePasswordError}
errorMessage={t("validation.password-invalid")}
helpText={t("help.passwordHelpText")}
/>
<InputField
label={t("validation.validatePassword")}
type="password"
onChange={this.handlePasswordValidationChange}
value={this.state ? this.state.validatePassword : ""}
validationError={this.state.passwordValidationError}
errorMessage={t("validation.passwordValidation-invalid")}
helpText={t("help.passwordConfirmHelpText")}
/>
<Checkbox <Checkbox
label={t("user.admin")} label={t("user.admin")}
onChange={this.handleAdminChange} onChange={this.handleAdminChange}
@@ -189,7 +195,7 @@ class UserForm extends React.Component<Props, State> {
); );
this.setState({ this.setState({
validatePasswordError: !userValidator.isPasswordValid(password), validatePasswordError: !userValidator.isPasswordValid(password),
passwordValidationError: validatePasswordError, passwordConfirmationError: validatePasswordError,
user: { ...this.state.user, password } user: { ...this.state.user, password }
}); });
}; };
@@ -201,7 +207,7 @@ class UserForm extends React.Component<Props, State> {
); );
this.setState({ this.setState({
validatePassword, validatePassword,
passwordValidationError: !validatePasswordError passwordConfirmationError: !validatePasswordError
}); });
}; };

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { User } from "@scm-manager/ui-types";
import { NavLink } from "@scm-manager/ui-components";
type Props = {
t: string => string,
user: User,
passwordUrl: String
};
class ChangePasswordNavLink extends React.Component<Props> {
render() {
const { t, passwordUrl } = this.props;
if (!this.hasPermissionToSetPassword()) {
return null;
}
return <NavLink label={t("set-password-button.label")} to={passwordUrl} />;
}
hasPermissionToSetPassword = () => {
return this.props.user._links.password;
};
}
export default translate("users")(ChangePasswordNavLink);

View File

@@ -0,0 +1,31 @@
import React from "react";
import { shallow } from "enzyme";
import "../../../tests/enzyme";
import "../../../tests/i18n";
import ChangePasswordNavLink from "./SetPasswordNavLink";
it("should render nothing, if the password link is missing", () => {
const user = {
_links: {}
};
const navLink = shallow(
<ChangePasswordNavLink user={user} passwordUrl="/user/password" />
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const user = {
_links: {
password: {
href: "/password"
}
}
};
const navLink = shallow(
<ChangePasswordNavLink user={user} passwordUrl="/user/password" />
);
expect(navLink.text()).not.toBe("");
});

View File

@@ -1,2 +1,3 @@
export { default as DeleteUserNavLink } from "./DeleteUserNavLink"; export { default as DeleteUserNavLink } from "./DeleteUserNavLink";
export { default as EditUserNavLink } from "./EditUserNavLink"; export { default as EditUserNavLink } from "./EditUserNavLink";
export { default as SetPasswordNavLink } from "./SetPasswordNavLink";

View File

@@ -0,0 +1,15 @@
//@flow
import { apiClient } from "@scm-manager/ui-components";
const CONTENT_TYPE_PASSWORD_OVERWRITE =
"application/vnd.scmm-passwordOverwrite+json;v=2";
export function updatePassword(url: string, password: string) {
return apiClient
.put(url, { newPassword: password }, CONTENT_TYPE_PASSWORD_OVERWRITE)
.then(response => {
return response;
})
.catch(err => {
return { error: err };
});
}

View File

@@ -0,0 +1,23 @@
//@flow
import fetchMock from "fetch-mock";
import { updatePassword } from "./updatePassword";
describe("get content type", () => {
const PASSWORD_URL = "/users/testuser/password";
const password = "testpw123";
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should update password", done => {
fetchMock.put("/api/v2" + PASSWORD_URL, 204);
updatePassword(PASSWORD_URL, password).then(content => {
done();
});
});
});

View File

@@ -7,7 +7,8 @@ import type { User } from "@scm-manager/ui-types";
import { import {
modifyUser, modifyUser,
isModifyUserPending, isModifyUserPending,
getModifyUserFailure getModifyUserFailure,
modifyUserReset
} from "../modules/users"; } from "../modules/users";
import type { History } from "history"; import type { History } from "history";
import { ErrorNotification } from "@scm-manager/ui-components"; import { ErrorNotification } from "@scm-manager/ui-components";
@@ -18,6 +19,7 @@ type Props = {
// dispatch functions // dispatch functions
modifyUser: (user: User, callback?: () => void) => void, modifyUser: (user: User, callback?: () => void) => void,
modifyUserReset: User => void,
// context objects // context objects
user: User, user: User,
@@ -25,6 +27,10 @@ type Props = {
}; };
class EditUser extends React.Component<Props> { class EditUser extends React.Component<Props> {
componentDidMount() {
const { modifyUserReset, user } = this.props;
modifyUserReset(user);
}
userModified = (user: User) => () => { userModified = (user: User) => () => {
this.props.history.push(`/user/${user.name}`); this.props.history.push(`/user/${user.name}`);
}; };
@@ -52,6 +58,9 @@ const mapDispatchToProps = dispatch => {
return { return {
modifyUser: (user: User, callback?: () => void) => { modifyUser: (user: User, callback?: () => void) => {
dispatch(modifyUser(user, callback)); dispatch(modifyUser(user, callback));
},
modifyUserReset: (user: User) => {
dispatch(modifyUserReset(user));
} }
}; };
}; };

View File

@@ -15,7 +15,7 @@ import EditUser from "./EditUser";
import type { User } from "@scm-manager/ui-types"; import type { User } from "@scm-manager/ui-types";
import type { History } from "history"; import type { History } from "history";
import { import {
fetchUser, fetchUserByName,
deleteUser, deleteUser,
getUserByName, getUserByName,
isFetchUserPending, isFetchUserPending,
@@ -24,9 +24,14 @@ import {
getDeleteUserFailure getDeleteUserFailure
} from "../modules/users"; } from "../modules/users";
import { DeleteUserNavLink, EditUserNavLink } from "./../components/navLinks"; import {
DeleteUserNavLink,
EditUserNavLink,
SetPasswordNavLink
} from "./../components/navLinks";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import { getUsersLink } from "../../modules/indexResource"; import { getUsersLink } from "../../modules/indexResource";
import SetUserPassword from "../components/SetUserPassword";
type Props = { type Props = {
name: string, name: string,
@@ -37,7 +42,7 @@ type Props = {
// dispatcher functions // dispatcher functions
deleteUser: (user: User, callback?: () => void) => void, deleteUser: (user: User, callback?: () => void) => void,
fetchUser: (string, string) => void, fetchUserByName: (string, string) => void,
// context objects // context objects
t: string => string, t: string => string,
@@ -47,7 +52,7 @@ type Props = {
class SingleUser extends React.Component<Props> { class SingleUser extends React.Component<Props> {
componentDidMount() { componentDidMount() {
this.props.fetchUser(this.props.usersLink, this.props.name); this.props.fetchUserByName(this.props.usersLink, this.props.name);
} }
userDeleted = () => { userDeleted = () => {
@@ -97,6 +102,10 @@ class SingleUser extends React.Component<Props> {
path={`${url}/edit`} path={`${url}/edit`}
component={() => <EditUser user={user} />} component={() => <EditUser user={user} />}
/> />
<Route
path={`${url}/password`}
component={() => <SetUserPassword user={user} />}
/>
</div> </div>
<div className="column"> <div className="column">
<Navigation> <Navigation>
@@ -106,6 +115,10 @@ class SingleUser extends React.Component<Props> {
label={t("single-user.information-label")} label={t("single-user.information-label")}
/> />
<EditUserNavLink user={user} editUrl={`${url}/edit`} /> <EditUserNavLink user={user} editUrl={`${url}/edit`} />
<SetPasswordNavLink
user={user}
passwordUrl={`${url}/password`}
/>
</Section> </Section>
<Section label={t("single-user.actions-label")}> <Section label={t("single-user.actions-label")}>
<DeleteUserNavLink user={user} deleteUser={this.deleteUser} /> <DeleteUserNavLink user={user} deleteUser={this.deleteUser} />
@@ -138,8 +151,8 @@ const mapStateToProps = (state, ownProps) => {
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchUser: (link: string, name: string) => { fetchUserByName: (link: string, name: string) => {
dispatch(fetchUser(link, name)); dispatch(fetchUserByName(link, name));
}, },
deleteUser: (user: User, callback?: () => void) => { deleteUser: (user: User, callback?: () => void) => {
dispatch(deleteUser(user, callback)); dispatch(deleteUser(user, callback));

View File

@@ -26,6 +26,7 @@ export const MODIFY_USER = "scm/users/MODIFY_USER";
export const MODIFY_USER_PENDING = `${MODIFY_USER}_${types.PENDING_SUFFIX}`; export const MODIFY_USER_PENDING = `${MODIFY_USER}_${types.PENDING_SUFFIX}`;
export const MODIFY_USER_SUCCESS = `${MODIFY_USER}_${types.SUCCESS_SUFFIX}`; export const MODIFY_USER_SUCCESS = `${MODIFY_USER}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_USER_FAILURE = `${MODIFY_USER}_${types.FAILURE_SUFFIX}`; export const MODIFY_USER_FAILURE = `${MODIFY_USER}_${types.FAILURE_SUFFIX}`;
export const MODIFY_USER_RESET = `${MODIFY_USER}_${types.RESET_SUFFIX}`;
export const DELETE_USER = "scm/users/DELETE"; export const DELETE_USER = "scm/users/DELETE";
export const DELETE_USER_PENDING = `${DELETE_USER}_${types.PENDING_SUFFIX}`; export const DELETE_USER_PENDING = `${DELETE_USER}_${types.PENDING_SUFFIX}`;
@@ -87,12 +88,20 @@ export function fetchUsersFailure(url: string, error: Error): Action {
} }
//fetch user //fetch user
export function fetchUser(link: string, name: string) { export function fetchUserByName(link: string, name: string) {
const userUrl = link.endsWith("/") ? link + name : link + "/" + name; const userUrl = link.endsWith("/") ? link + name : link + "/" + name;
return fetchUser(userUrl, name);
}
export function fetchUserByLink(user: User) {
return fetchUser(user._links.self.href, user.name);
}
function fetchUser(link: string, name: string) {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(fetchUserPending(name)); dispatch(fetchUserPending(name));
return apiClient return apiClient
.get(userUrl) .get(link)
.then(response => { .then(response => {
return response.json(); return response.json();
}) })
@@ -195,6 +204,9 @@ export function modifyUser(user: User, callback?: () => void) {
callback(); callback();
} }
}) })
.then(() => {
dispatch(fetchUserByLink(user));
})
.catch(err => { .catch(err => {
dispatch(modifyUserFailure(user, err)); dispatch(modifyUserFailure(user, err));
}); });
@@ -228,6 +240,13 @@ export function modifyUserFailure(user: User, error: Error): Action {
}; };
} }
export function modifyUserReset(user: User): Action {
return {
type: MODIFY_USER_RESET,
itemId: user.name
};
}
//delete user //delete user
export function deleteUser(user: User, callback?: () => void) { export function deleteUser(user: User, callback?: () => void) {
@@ -365,9 +384,6 @@ function byNamesReducer(state: any = {}, action: any = {}) {
case FETCH_USER_SUCCESS: case FETCH_USER_SUCCESS:
return reducerByName(state, action.payload.name, action.payload); return reducerByName(state, action.payload.name, action.payload);
case MODIFY_USER_SUCCESS:
return reducerByName(state, action.payload.name, action.payload);
case DELETE_USER_SUCCESS: case DELETE_USER_SUCCESS:
const newUserByNames = deleteUserInUsersByNames( const newUserByNames = deleteUserInUsersByNames(
state, state,

View File

@@ -20,7 +20,8 @@ import reducer, {
FETCH_USERS_FAILURE, FETCH_USERS_FAILURE,
FETCH_USERS_PENDING, FETCH_USERS_PENDING,
FETCH_USERS_SUCCESS, FETCH_USERS_SUCCESS,
fetchUser, fetchUserByLink,
fetchUserByName,
fetchUserSuccess, fetchUserSuccess,
getFetchUserFailure, getFetchUserFailure,
fetchUsers, fetchUsers,
@@ -33,7 +34,6 @@ import reducer, {
MODIFY_USER_PENDING, MODIFY_USER_PENDING,
MODIFY_USER_SUCCESS, MODIFY_USER_SUCCESS,
modifyUser, modifyUser,
modifyUserSuccess,
getUsersFromState, getUsersFromState,
FETCH_USERS, FETCH_USERS,
getFetchUsersFailure, getFetchUsersFailure,
@@ -124,6 +124,7 @@ const response = {
const URL = "users"; const URL = "users";
const USERS_URL = "/api/v2/users"; const USERS_URL = "/api/v2/users";
const USER_ZAPHOD_URL = "http://localhost:8081/api/v2/users/zaphod";
const error = new Error("KAPUTT"); const error = new Error("KAPUTT");
@@ -166,11 +167,37 @@ describe("users fetch()", () => {
}); });
}); });
it("should sucessfully fetch single user", () => { it("should sucessfully fetch single user by name", () => {
fetchMock.getOnce(USERS_URL + "/zaphod", userZaphod); fetchMock.getOnce(USERS_URL + "/zaphod", userZaphod);
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchUser(URL, "zaphod")).then(() => { return store.dispatch(fetchUserByName(URL, "zaphod")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USER_PENDING);
expect(actions[1].type).toEqual(FETCH_USER_SUCCESS);
expect(actions[1].payload).toBeDefined();
});
});
it("should fail fetching single user by name on HTTP 500", () => {
fetchMock.getOnce(USERS_URL + "/zaphod", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchUserByName(URL, "zaphod")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USER_PENDING);
expect(actions[1].type).toEqual(FETCH_USER_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should sucessfully fetch single user", () => {
fetchMock.getOnce(USER_ZAPHOD_URL, userZaphod);
const store = mockStore({});
return store.dispatch(fetchUserByLink(userZaphod)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USER_PENDING); expect(actions[0].type).toEqual(FETCH_USER_PENDING);
expect(actions[1].type).toEqual(FETCH_USER_SUCCESS); expect(actions[1].type).toEqual(FETCH_USER_SUCCESS);
@@ -179,12 +206,12 @@ describe("users fetch()", () => {
}); });
it("should fail fetching single user on HTTP 500", () => { it("should fail fetching single user on HTTP 500", () => {
fetchMock.getOnce(USERS_URL + "/zaphod", { fetchMock.getOnce(USER_ZAPHOD_URL, {
status: 500 status: 500
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(fetchUser(URL, "zaphod")).then(() => { return store.dispatch(fetchUserByLink(userZaphod)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USER_PENDING); expect(actions[0].type).toEqual(FETCH_USER_PENDING);
expect(actions[1].type).toEqual(FETCH_USER_FAILURE); expect(actions[1].type).toEqual(FETCH_USER_FAILURE);
@@ -242,23 +269,26 @@ describe("users fetch()", () => {
}); });
it("successfully update user", () => { it("successfully update user", () => {
fetchMock.putOnce("http://localhost:8081/api/v2/users/zaphod", { fetchMock.putOnce(USER_ZAPHOD_URL, {
status: 204 status: 204
}); });
fetchMock.getOnce(USER_ZAPHOD_URL, userZaphod);
const store = mockStore({}); const store = mockStore({});
return store.dispatch(modifyUser(userZaphod)).then(() => { return store.dispatch(modifyUser(userZaphod)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions.length).toBe(2); expect(actions.length).toBe(3);
expect(actions[0].type).toEqual(MODIFY_USER_PENDING); expect(actions[0].type).toEqual(MODIFY_USER_PENDING);
expect(actions[1].type).toEqual(MODIFY_USER_SUCCESS); expect(actions[1].type).toEqual(MODIFY_USER_SUCCESS);
expect(actions[2].type).toEqual(FETCH_USER_PENDING);
}); });
}); });
it("should call callback, after successful modified user", () => { it("should call callback, after successful modified user", () => {
fetchMock.putOnce("http://localhost:8081/api/v2/users/zaphod", { fetchMock.putOnce(USER_ZAPHOD_URL, {
status: 204 status: 204
}); });
fetchMock.getOnce(USER_ZAPHOD_URL, userZaphod);
let called = false; let called = false;
const callMe = () => { const callMe = () => {
@@ -415,20 +445,6 @@ describe("users reducer", () => {
expect(newState.byNames["ford"]).toBe(userFord); expect(newState.byNames["ford"]).toBe(userFord);
expect(newState.list.entries).toEqual(["zaphod"]); expect(newState.list.entries).toEqual(["zaphod"]);
}); });
it("should update state according to MODIFY_USER_SUCCESS action", () => {
const newState = reducer(
{
byNames: {
ford: {
name: "ford"
}
}
},
modifyUserSuccess(userFord)
);
expect(newState.byNames["ford"]).toBe(userFord);
});
}); });
describe("selector tests", () => { describe("selector tests", () => {

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import com.webcohesion.enunciate.metadata.rs.TypeHint;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.PageResult; import sonia.scm.PageResult;
import sonia.scm.repository.Branch;
import sonia.scm.repository.Branches; import sonia.scm.repository.Branches;
import sonia.scm.repository.Changeset; import sonia.scm.repository.Changeset;
import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.ChangesetPagingResult;
@@ -26,6 +27,7 @@ import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam; import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.io.IOException; import java.io.IOException;
import java.util.List;
public class BranchRootResource { public class BranchRootResource {
@@ -124,6 +126,49 @@ public class BranchRootResource {
} }
} }
@Path("{branch}/diffchangesets/{otherBranchName}")
@GET
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the changeset"),
@ResponseCode(code = 404, condition = "not found, no changesets available in the repository"),
@ResponseCode(code = 500, condition = "internal server error")
})
@Produces(VndMediaType.CHANGESET_COLLECTION)
@TypeHint(CollectionDto.class)
public Response changesetDiff(@PathParam("namespace") String namespace,
@PathParam("name") String name,
@PathParam("branch") String branchName,
@PathParam("otherBranchName") String otherBranchName,
@DefaultValue("0") @QueryParam("page") int page,
@DefaultValue("10") @QueryParam("pageSize") int pageSize) throws Exception {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
List<Branch> allBranches = repositoryService.getBranchesCommand().getBranches().getBranches();
if (allBranches.stream().noneMatch(branch -> branchName.equals(branch.getName()))) {
throw new NotFoundException("branch", branchName);
}
if (allBranches.stream().noneMatch(branch -> otherBranchName.equals(branch.getName()))) {
throw new NotFoundException("branch", otherBranchName);
}
Repository repository = repositoryService.getRepository();
RepositoryPermissions.read(repository).check();
ChangesetPagingResult changesets = new PagedLogCommandBuilder(repositoryService)
.page(page)
.pageSize(pageSize)
.create()
.setBranch(branchName)
.setAncestorChangeset(otherBranchName)
.getChangesets();
if (changesets != null && changesets.getChangesets() != null) {
PageResult<Changeset> pageResult = new PageResult<>(changesets.getChangesets(), changesets.getTotal());
return Response.ok(branchChangesetCollectionToDtoMapper.map(page, pageSize, pageResult, repository, branchName)).build();
} else {
return Response.ok().build();
}
}
}
/** /**
* Returns the branches for a repository. * Returns the branches for a repository.
* *

View File

@@ -28,6 +28,7 @@ public abstract class BranchToBranchDtoMapper {
Links.Builder linksBuilder = linkingTo() Links.Builder linksBuilder = linkingTo()
.self(resourceLinks.branch().self(namespaceAndName, target.getName())) .self(resourceLinks.branch().self(namespaceAndName, target.getName()))
.single(linkBuilder("history", resourceLinks.branch().history(namespaceAndName, target.getName())).build()) .single(linkBuilder("history", resourceLinks.branch().history(namespaceAndName, target.getName())).build())
.single(linkBuilder("changesetDiff", resourceLinks.branch().changesetDiff(namespaceAndName, target.getName())).build())
.single(linkBuilder("changeset", resourceLinks.changeset().changeset(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build()) .single(linkBuilder("changeset", resourceLinks.changeset().changeset(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build())
.single(linkBuilder("source", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build()); .single(linkBuilder("source", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build());
target.add(linksBuilder.build()); target.add(linksBuilder.build());

View File

@@ -322,6 +322,10 @@ class ResourceLinks {
public String history(NamespaceAndName namespaceAndName, String branch) { public String history(NamespaceAndName namespaceAndName, String branch) {
return branchLinkBuilder.method("getRepositoryResource").parameters(namespaceAndName.getNamespace(), namespaceAndName.getName()).method("branches").parameters().method("history").parameters(branch).href(); return branchLinkBuilder.method("getRepositoryResource").parameters(namespaceAndName.getNamespace(), namespaceAndName.getName()).method("branches").parameters().method("history").parameters(branch).href();
} }
public String changesetDiff(NamespaceAndName namespaceAndName, String branch) {
return branchLinkBuilder.method("getRepositoryResource").parameters(namespaceAndName.getNamespace(), namespaceAndName.getName()).method("branches").parameters().method("changesetDiff").parameters(branch, "").href() + "{otherBranch}";
}
} }
public BranchCollectionLinks branchCollection() { public BranchCollectionLinks branchCollection() {