This commit is contained in:
Florian Scholdei
2019-04-17 14:27:17 +02:00
73 changed files with 2311 additions and 530 deletions

View File

@@ -0,0 +1,12 @@
package sonia.scm.api.v2.resources;
import sonia.scm.plugin.ExtensionPoint;
import java.net.URI;
import java.util.Optional;
@ExtensionPoint(multi = false)
@FunctionalInterface
public interface LogoutRedirection {
Optional<URI> afterLogoutRedirectTo();
}

View File

@@ -28,7 +28,7 @@ public abstract class GitRepositoryConfigMapper {
@AfterMapping @AfterMapping
void appendLinks(@MappingTarget GitRepositoryConfigDto target, @Context Repository repository) { void appendLinks(@MappingTarget GitRepositoryConfigDto target, @Context Repository repository) {
Links.Builder linksBuilder = linkingTo().self(self()); Links.Builder linksBuilder = linkingTo().self(self());
if (RepositoryPermissions.modify(repository).isPermitted()) { if (RepositoryPermissions.custom("git", repository).isPermitted()) {
linksBuilder.single(link("update", update())); linksBuilder.single(link("update", update()));
} }
target.add(linksBuilder.build()); target.add(linksBuilder.build());

View File

@@ -70,7 +70,7 @@ public class GitRepositoryConfigResource {
}) })
public Response setRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name, GitRepositoryConfigDto dto) { public Response setRepositoryConfig(@PathParam("namespace") String namespace, @PathParam("name") String name, GitRepositoryConfigDto dto) {
Repository repository = getRepository(namespace, name); Repository repository = getRepository(namespace, name);
RepositoryPermissions.modify(repository).check(); RepositoryPermissions.custom("git", repository).check();
ConfigurationStore<GitRepositoryConfig> repositoryConfigStore = getStore(repository); ConfigurationStore<GitRepositoryConfig> repositoryConfigStore = getStore(repository);
GitRepositoryConfig config = repositoryConfigMapper.map(dto); GitRepositoryConfig config = repositoryConfigMapper.map(dto);
repositoryConfigStore.set(config); repositoryConfigStore.set(config);

View File

@@ -8,11 +8,10 @@ type Props = {
repository: Repository, repository: Repository,
// context props // context props
t: (string) => string t: string => string
}; };
class CloneInformation extends React.Component<Props> { class CloneInformation extends React.Component<Props> {
render() { render() {
const { url, repository, t } = this.props; const { url, repository, t } = this.props;
@@ -51,7 +50,6 @@ class CloneInformation extends React.Component<Props> {
</div> </div>
); );
} }
} }
export default translate("plugins")(CloneInformation); export default translate("plugins")(CloneInformation);

View File

@@ -0,0 +1,30 @@
//@flow
import React from "react";
import type { Branch } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
type Props = {
branch: Branch,
t: string => string
};
class GitBranchInformation extends React.Component<Props> {
render() {
const { branch, t } = this.props;
return (
<div>
<h4>{t("scm-git-plugin.information.fetch")}</h4>
<pre>
<code>git fetch</code>
</pre>
<h4>{t("scm-git-plugin.information.checkout")}</h4>
<pre>
<code>git checkout {branch.name}</code>
</pre>
</div>
);
}
}
export default translate("plugins")(GitBranchInformation);

View File

@@ -10,7 +10,7 @@ type Configuration = {
gcExpression?: string, gcExpression?: string,
nonFastForwardDisallowed: boolean, nonFastForwardDisallowed: boolean,
_links: Links _links: Links
} };
type Props = { type Props = {
initialConfiguration: Configuration, initialConfiguration: Configuration,
@@ -19,25 +19,24 @@ type Props = {
onConfigurationChange: (Configuration, boolean) => void, onConfigurationChange: (Configuration, boolean) => void,
// context props // context props
t: (string) => string t: string => string
} };
type State = Configuration & { type State = Configuration & {};
}
class GitConfigurationForm extends React.Component<Props, State> { class GitConfigurationForm extends React.Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { ...props.initialConfiguration }; this.state = { ...props.initialConfiguration };
} }
handleChange = (value: any, name: string) => { handleChange = (value: any, name: string) => {
this.setState({ this.setState(
[name]: value {
}, () => this.props.onConfigurationChange(this.state, true)); [name]: value
},
() => this.props.onConfigurationChange(this.state, true)
);
}; };
render() { render() {
@@ -46,24 +45,25 @@ class GitConfigurationForm extends React.Component<Props, State> {
return ( return (
<> <>
<InputField name="gcExpression" <InputField
label={t("scm-git-plugin.config.gcExpression")} name="gcExpression"
helpText={t("scm-git-plugin.config.gcExpressionHelpText")} label={t("scm-git-plugin.config.gcExpression")}
value={gcExpression} helpText={t("scm-git-plugin.config.gcExpressionHelpText")}
onChange={this.handleChange} value={gcExpression}
disabled={readOnly} onChange={this.handleChange}
disabled={readOnly}
/> />
<Checkbox name="nonFastForwardDisallowed" <Checkbox
label={t("scm-git-plugin.config.nonFastForwardDisallowed")} name="nonFastForwardDisallowed"
helpText={t("scm-git-plugin.config.nonFastForwardDisallowedHelpText")} label={t("scm-git-plugin.config.nonFastForwardDisallowed")}
checked={nonFastForwardDisallowed} helpText={t("scm-git-plugin.config.nonFastForwardDisallowedHelpText")}
onChange={this.handleChange} checked={nonFastForwardDisallowed}
disabled={readOnly} onChange={this.handleChange}
disabled={readOnly}
/> />
</> </>
); );
} }
} }
export default translate("plugins")(GitConfigurationForm); export default translate("plugins")(GitConfigurationForm);

View File

@@ -121,6 +121,7 @@ class RepositoryConfig extends React.Component<Props, State> {
if (!(loadingBranches || loadingDefaultBranch)) { if (!(loadingBranches || loadingDefaultBranch)) {
return ( return (
<> <>
<hr />
<Subtitle subtitle={t("scm-git-plugin.repo-config.title")}/> <Subtitle subtitle={t("scm-git-plugin.repo-config.title")}/>
{this.renderBranchChangedNotification()} {this.renderBranchChangedNotification()}
<form onSubmit={this.submit}> <form onSubmit={this.submit}>
@@ -133,7 +134,6 @@ class RepositoryConfig extends React.Component<Props, State> {
/> />
{ submitButton } { submitButton }
</form> </form>
<hr />
</> </>
); );
} else { } else {

View File

@@ -6,6 +6,7 @@ import GitAvatar from "./GitAvatar";
import {ConfigurationBinder as cfgBinder} from "@scm-manager/ui-components"; import {ConfigurationBinder as cfgBinder} from "@scm-manager/ui-components";
import GitGlobalConfiguration from "./GitGlobalConfiguration"; import GitGlobalConfiguration from "./GitGlobalConfiguration";
import GitBranchInformation from "./GitBranchInformation";
import GitMergeInformation from "./GitMergeInformation"; import GitMergeInformation from "./GitMergeInformation";
import RepositoryConfig from "./RepositoryConfig"; import RepositoryConfig from "./RepositoryConfig";
@@ -20,6 +21,11 @@ binder.bind(
ProtocolInformation, ProtocolInformation,
gitPredicate gitPredicate
); );
binder.bind(
"repos.branch-details.information",
GitBranchInformation,
gitPredicate
);
binder.bind( binder.bind(
"repos.repository-merge.information", "repos.repository-merge.information",
GitMergeInformation, GitMergeInformation,

View File

@@ -1,9 +1,11 @@
{ {
"scm-git-plugin": { "scm-git-plugin": {
"information": { "information": {
"clone" : "Repository klonen", "clone": "Repository klonen",
"create" : "Neues Repository erstellen", "create": "Neues Repository erstellen",
"replace" : "Ein bestehendes Repository aktualisieren", "replace": "Ein bestehendes Repository aktualisieren",
"fetch": "Remote-Änderungen herunterladen",
"checkout": "Branch wechseln",
"merge": { "merge": {
"heading": "Merge des Source Branch in den Target Branch", "heading": "Merge des Source Branch in den Target Branch",
"checkout": "1. Sicherstellen, dass der Workspace aufgeräumt ist und der Target Branch ausgecheckt wurde.", "checkout": "1. Sicherstellen, dass der Workspace aufgeräumt ist und der Target Branch ausgecheckt wurde.",
@@ -37,7 +39,7 @@
"success": "Der standard Branch wurde geändert!" "success": "Der standard Branch wurde geändert!"
} }
}, },
"permissions" : { "permissions": {
"configuration": { "configuration": {
"read,write": { "read,write": {
"git": { "git": {

View File

@@ -4,6 +4,8 @@
"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",
"fetch": "Get remote changes",
"checkout": "Switch branch",
"merge": { "merge": {
"heading": "How to merge source branch into target branch", "heading": "How to merge source branch into target branch",
"checkout": "1. Make sure your workspace is clean and checkout target branch", "checkout": "1. Make sure your workspace is clean and checkout target branch",

View File

@@ -10,4 +10,4 @@ writer = configuration:write:git
readerWriter = configuration:*:git,repository:*:id readerWriter = configuration:*:git,repository:*:id
admin = * admin = *
repoRead = repository:read:* repoRead = repository:read:*
repoWrite = repository:modify:* repoWrite = repository:modify:*,repository:git:*

View File

@@ -0,0 +1,30 @@
//@flow
import React from "react";
import type { Branch } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
type Props = {
branch: Branch,
t: string => string
};
class HgBranchInformation extends React.Component<Props> {
render() {
const { branch, t } = this.props;
return (
<div>
<h4>{t("scm-hg-plugin.information.fetch")}</h4>
<pre>
<code>hg pull</code>
</pre>
<h4>{t("scm-hg-plugin.information.checkout")}</h4>
<pre>
<code>hg update {branch.name}</code>
</pre>
</div>
);
}
}
export default translate("plugins")(HgBranchInformation);

View File

@@ -4,14 +4,29 @@ import ProtocolInformation from "./ProtocolInformation";
import HgAvatar from "./HgAvatar"; import HgAvatar from "./HgAvatar";
import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components"; import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components";
import HgGlobalConfiguration from "./HgGlobalConfiguration"; import HgGlobalConfiguration from "./HgGlobalConfiguration";
import HgBranchInformation from "./HgBranchInformation";
const hgPredicate = (props: Object) => { const hgPredicate = (props: Object) => {
return props.repository && props.repository.type === "hg"; return props.repository && props.repository.type === "hg";
}; };
binder.bind("repos.repository-details.information", ProtocolInformation, hgPredicate); binder.bind(
"repos.repository-details.information",
ProtocolInformation,
hgPredicate
);
binder.bind(
"repos.branch-details.information",
HgBranchInformation,
hgPredicate
);
binder.bind("repos.repository-avatar", HgAvatar, hgPredicate); binder.bind("repos.repository-avatar", HgAvatar, hgPredicate);
// bind global configuration // bind global configuration
cfgBinder.bindGlobal("/hg", "scm-hg-plugin.config.link", "hgConfig", HgGlobalConfiguration); cfgBinder.bindGlobal(
"/hg",
"scm-hg-plugin.config.link",
"hgConfig",
HgGlobalConfiguration
);

View File

@@ -3,7 +3,9 @@
"information": { "information": {
"clone" : "Repository klonen", "clone" : "Repository klonen",
"create" : "Neues Repository erstellen", "create" : "Neues Repository erstellen",
"replace" : "Ein bestehendes Repository aktualisieren" "replace" : "Ein bestehendes Repository aktualisieren",
"fetch": "Remote-Änderungen herunterladen",
"checkout": "Branch wechseln"
}, },
"config": { "config": {
"link": "Mercurial", "link": "Mercurial",

View File

@@ -3,7 +3,9 @@
"information": { "information": {
"clone" : "Clone the repository", "clone" : "Clone the repository",
"create" : "Create a new repository", "create" : "Create a new repository",
"replace" : "Push an existing repository" "replace" : "Push an existing repository",
"fetch": "Get remote changes",
"checkout": "Switch branch"
}, },
"config": { "config": {
"link": "Mercurial", "link": "Mercurial",

View File

@@ -30,9 +30,9 @@
"@scm-manager/ui-types": "2.0.0-SNAPSHOT", "@scm-manager/ui-types": "2.0.0-SNAPSHOT",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"moment": "^2.22.2", "moment": "^2.22.2",
"react": "^16.5.2", "react": "^16.8.6",
"react-dom": "^16.8.6",
"react-diff-view": "^1.8.1", "react-diff-view": "^1.8.1",
"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",
@@ -63,4 +63,4 @@
] ]
] ]
} }
} }

View File

@@ -11,7 +11,7 @@ class MailLink extends React.Component<Props> {
if (!address) { if (!address) {
return null; return null;
} }
return <a href={"mailto: " + address}>{address}</a>; return <a href={"mailto:" + address}>{address}</a>;
} }
} }

View File

@@ -15,7 +15,8 @@ type Props = {
value?: string, value?: string,
onChange: (value: string, name?: string) => void, onChange: (value: string, name?: string) => void,
loading?: boolean, loading?: boolean,
helpText?: string helpText?: string,
disabled?: boolean
}; };
class Select extends React.Component<Props> { class Select extends React.Component<Props> {
@@ -34,7 +35,7 @@ class Select extends React.Component<Props> {
}; };
render() { render() {
const { options, value, label, helpText, loading } = this.props; const { options, value, label, helpText, loading, disabled } = this.props;
const loadingClass = loading ? "is-loading" : ""; const loadingClass = loading ? "is-loading" : "";
@@ -51,6 +52,7 @@ class Select extends React.Component<Props> {
}} }}
value={value} value={value}
onChange={this.handleInput} onChange={this.handleInput}
disabled={disabled}
> >
{options.map(opt => { {options.map(opt => {
return ( return (

View File

@@ -11,6 +11,7 @@ import {
import injectSheets from "react-jss"; import injectSheets from "react-jss";
import classNames from "classnames"; import classNames from "classnames";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import {Button} from "../buttons";
const styles = { const styles = {
panel: { panel: {
@@ -39,14 +40,16 @@ type Props = DiffObjectProps & {
}; };
type State = { type State = {
collapsed: boolean collapsed: boolean,
sideBySide: boolean
}; };
class DiffFile extends React.Component<Props, State> { class DiffFile extends React.Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
collapsed: false collapsed: false,
sideBySide: false
}; };
} }
@@ -56,6 +59,12 @@ class DiffFile extends React.Component<Props, State> {
})); }));
}; };
toggleSideBySide = () => {
this.setState(state => ({
sideBySide: !state.sideBySide
}));
};
setCollapse = (collapsed: boolean) => { setCollapse = (collapsed: boolean) => {
this.setState({ this.setState({
collapsed collapsed
@@ -149,10 +158,10 @@ class DiffFile extends React.Component<Props, State> {
file, file,
fileControlFactory, fileControlFactory,
fileAnnotationFactory, fileAnnotationFactory,
sideBySide, classes,
classes t
} = this.props; } = this.props;
const { collapsed } = this.state; const { collapsed, sideBySide } = this.state;
const viewType = sideBySide ? "split" : "unified"; const viewType = sideBySide ? "split" : "unified";
let body = null; let body = null;
@@ -173,14 +182,10 @@ class DiffFile extends React.Component<Props, State> {
} }
const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null; const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null;
return ( return <div className={classNames("panel", classes.panel)}>
<div className={classNames("panel", classes.panel)}>
<div className="panel-heading"> <div className="panel-heading">
<div className="level"> <div className="level">
<div <div className={classNames("level-left", classes.titleHeader)} onClick={this.toggleCollapse}>
className={classNames("level-left", classes.titleHeader)}
onClick={this.toggleCollapse}
>
<i className={icon} /> <i className={icon} />
<span className={classes.title}> <span className={classes.title}>
{this.renderFileTitle(file)} {this.renderFileTitle(file)}
@@ -189,12 +194,21 @@ class DiffFile extends React.Component<Props, State> {
{this.renderChangeTag(file)} {this.renderChangeTag(file)}
</span> </span>
</div> </div>
<div className="level-right">{fileControls}</div> <div className="level-right">
<Button action={this.toggleSideBySide}>
<span className="icon is-small">
<i className={classNames("fas", sideBySide ? "fa-align-left" : "fa-columns")} />
</span>
<span className="is-hidden-mobile">
{t(sideBySide ? "diff.combined" : "diff.sideBySide")}
</span>
</Button>
{fileControls}
</div>
</div> </div>
</div> </div>
{body} {body}
</div> </div>;
);
} }
} }

View File

@@ -2,13 +2,13 @@
import React from "react"; import React from "react";
import type { Changeset } from "@scm-manager/ui-types"; import type { Changeset } from "@scm-manager/ui-types";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { ExtensionPoint } from "@scm-manager/ui-extensions";
import {translate} from "react-i18next"; import { translate } from "react-i18next";
type Props = { type Props = {
changeset: Changeset, changeset: Changeset,
// context props // context props
t: (string) => string t: string => string
}; };
class ChangesetAuthor extends React.Component<Props> { class ChangesetAuthor extends React.Component<Props> {
@@ -28,7 +28,10 @@ class ChangesetAuthor extends React.Component<Props> {
renderWithMail(name: string, mail: string) { renderWithMail(name: string, mail: string) {
const { t } = this.props; const { t } = this.props;
return ( return (
<a href={"mailto: " + mail} title={t("changeset.author.mailto") + " " + mail}> <a
href={"mailto:" + mail}
title={t("changeset.author.mailto") + " " + mail}
>
{name} {name}
</a> </a>
); );
@@ -44,7 +47,7 @@ class ChangesetAuthor extends React.Component<Props> {
props={{ changeset: this.props.changeset }} props={{ changeset: this.props.changeset }}
renderAll={true} renderAll={true}
/> />
</> </>
); );
} }
} }

View File

@@ -11,11 +11,10 @@ type Props = {
changeset: Changeset, changeset: Changeset,
// context props // context props
t: (string) => string t: string => string
} };
class ChangesetButtonGroup extends React.Component<Props> { class ChangesetButtonGroup extends React.Component<Props> {
render() { render() {
const { repository, changeset, t } = this.props; const { repository, changeset, t } = this.props;
@@ -26,7 +25,7 @@ class ChangesetButtonGroup extends React.Component<Props> {
<ButtonGroup className="is-pulled-right"> <ButtonGroup className="is-pulled-right">
<Button link={changesetLink}> <Button link={changesetLink}>
<span className="icon"> <span className="icon">
<i className="fas fa-code-branch"></i> <i className="fas fa-exchange-alt" />
</span> </span>
<span className="is-hidden-mobile is-hidden-tablet-only"> <span className="is-hidden-mobile is-hidden-tablet-only">
{t("changeset.buttons.details")} {t("changeset.buttons.details")}
@@ -34,7 +33,7 @@ class ChangesetButtonGroup extends React.Component<Props> {
</Button> </Button>
<Button link={sourcesLink}> <Button link={sourcesLink}>
<span className="icon"> <span className="icon">
<i className="fas fa-code"></i> <i className="fas fa-code" />
</span> </span>
<span className="is-hidden-mobile is-hidden-tablet-only"> <span className="is-hidden-mobile is-hidden-tablet-only">
{t("changeset.buttons.sources")} {t("changeset.buttons.sources")}
@@ -43,7 +42,6 @@ class ChangesetButtonGroup extends React.Component<Props> {
</ButtonGroup> </ButtonGroup>
); );
} }
} }
export default translate("repos")(ChangesetButtonGroup); export default translate("repos")(ChangesetButtonGroup);

View File

@@ -6623,14 +6623,14 @@ react-dom@^16.4.2:
prop-types "^15.6.2" prop-types "^15.6.2"
scheduler "^0.10.0" scheduler "^0.10.0"
react-dom@^16.5.2: react-dom@^16.8.6:
version "16.5.2" version "16.8.6"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f"
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" object-assign "^4.1.1"
prop-types "^15.6.2" prop-types "^15.6.2"
schedule "^0.5.0" scheduler "^0.13.6"
react-i18next@^7.11.0: react-i18next@^7.11.0:
version "7.13.0" version "7.13.0"
@@ -6755,14 +6755,14 @@ react@^16.4.2:
prop-types "^15.6.2" prop-types "^15.6.2"
scheduler "^0.10.0" scheduler "^0.10.0"
react@^16.5.2: react@^16.8.6:
version "16.5.2" version "16.8.6"
resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42" resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" object-assign "^4.1.1"
prop-types "^15.6.2" prop-types "^15.6.2"
schedule "^0.5.0" scheduler "^0.13.6"
read-only-stream@^2.0.0: read-only-stream@^2.0.0:
version "2.0.0" version "2.0.0"
@@ -7229,6 +7229,13 @@ scheduler@^0.10.0:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" object-assign "^4.1.1"
scheduler@^0.13.6:
version "0.13.6"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889"
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
select@^1.1.2: select@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"

View File

@@ -4,5 +4,11 @@ import type {Links} from "./hal";
export type Branch = { export type Branch = {
name: string, name: string,
revision: string, revision: string,
defaultBranch?: boolean,
_links: Links _links: Links
} }
export type BranchRequest = {
name: string,
parent: string
}

View File

@@ -9,7 +9,7 @@ export type { Group, Member } from "./Group";
export type { Repository, RepositoryCollection, RepositoryGroup } from "./Repositories"; export type { Repository, RepositoryCollection, RepositoryGroup } from "./Repositories";
export type { RepositoryType, RepositoryTypeCollection } from "./RepositoryTypes"; export type { RepositoryType, RepositoryTypeCollection } from "./RepositoryTypes";
export type { Branch } from "./Branches"; export type { Branch, BranchRequest } from "./Branches";
export type { Changeset } from "./Changesets"; export type { Changeset } from "./Changesets";

View File

@@ -17,11 +17,12 @@
"i18next-browser-languagedetector": "^2.2.2", "i18next-browser-languagedetector": "^2.2.2",
"i18next-fetch-backend": "^0.1.0", "i18next-fetch-backend": "^0.1.0",
"jss-nested": "^6.0.1", "jss-nested": "^6.0.1",
"memoize-one": "^5.0.4",
"moment": "^2.22.2", "moment": "^2.22.2",
"node-sass": "^4.9.3", "node-sass": "^4.9.3",
"postcss-easy-import": "^3.0.0", "postcss-easy-import": "^3.0.0",
"react": "^16.8.6",
"query-string": "5", "query-string": "5",
"react": "^16.8.6",
"react-diff-view": "^1.8.1", "react-diff-view": "^1.8.1",
"react-dom": "^16.8.6", "react-dom": "^16.8.6",
"react-i18next": "^7.9.0", "react-i18next": "^7.9.0",
@@ -53,7 +54,7 @@
"pre-commit": "jest && flow && eslint src" "pre-commit": "jest && flow && eslint src"
}, },
"devDependencies": { "devDependencies": {
"@scm-manager/ui-bundler": "^0.0.26", "@scm-manager/ui-bundler": "^0.0.27",
"concat": "^1.0.3", "concat": "^1.0.3",
"copyfiles": "^2.0.0", "copyfiles": "^2.0.0",
"enzyme": "^3.3.0", "enzyme": "^3.3.0",

View File

@@ -10,7 +10,8 @@
}, },
"groups": { "groups": {
"title": "Gruppen", "title": "Gruppen",
"subtitle": "Verwaltung der Gruppen" "subtitle": "Verwaltung der Gruppen",
"noGroups": "Keine Gruppen gefunden."
}, },
"singleGroup": { "singleGroup": {
"errorTitle": "Fehler", "errorTitle": "Fehler",

View File

@@ -26,6 +26,7 @@
"menu": { "menu": {
"navigationLabel": "Repository Navigation", "navigationLabel": "Repository Navigation",
"informationNavLink": "Informationen", "informationNavLink": "Informationen",
"branchesNavLink": "Branches",
"historyNavLink": "Commits", "historyNavLink": "Commits",
"sourcesNavLink": "Sources", "sourcesNavLink": "Sources",
"settingsNavLink": "Einstellungen", "settingsNavLink": "Einstellungen",
@@ -36,15 +37,39 @@
"overview": { "overview": {
"title": "Repositories", "title": "Repositories",
"subtitle": "Übersicht aller verfügbaren Repositories", "subtitle": "Übersicht aller verfügbaren Repositories",
"noRepositories": "Keine Repositories gefunden.",
"createButton": "Repository erstellen" "createButton": "Repository erstellen"
}, },
"create": { "create": {
"title": "Repository erstellen", "title": "Repository erstellen",
"subtitle": "Erstellen eines neuen Repository" "subtitle": "Erstellen eines neuen Repository"
}, },
"branches": {
"overview": {
"title": "Übersicht aller verfügbaren Branches",
"noBranches": "Keine Branches gefunden.",
"createButton": "Branch erstellen"
},
"table": {
"branches": "Branches"
},
"create": {
"title": "Branch erstellen",
"source": "Quellbranch",
"name": "Name",
"submit": "Branch erstellen"
}
},
"branch": {
"name": "Name:",
"commits": "Commits",
"sources": "Sources",
"defaultTag": "Default"
},
"changesets": { "changesets": {
"errorTitle": "Fehler", "errorTitle": "Fehler",
"errorSubtitle": "Changesets konnten nicht abgerufen werden", "errorSubtitle": "Changesets konnten nicht abgerufen werden",
"noChangesets": "Keine Changesets in diesem Branch gefunden.",
"branchSelectorLabel": "Branches" "branchSelectorLabel": "Branches"
}, },
"changeset": { "changeset": {
@@ -83,7 +108,8 @@
"lastModified": "Zuletzt bearbeitet", "lastModified": "Zuletzt bearbeitet",
"description": "Beschreibung", "description": "Beschreibung",
"size": "Größe" "size": "Größe"
} },
"noSources": "Keine Sources in diesem Branch gefunden."
}, },
"permission": { "permission": {
"title": "Berechtigungen bearbeiten", "title": "Berechtigungen bearbeiten",
@@ -147,5 +173,9 @@
"submit": "Ja", "submit": "Ja",
"cancel": "Nein" "cancel": "Nein"
} }
},
"diff": {
"sideBySide": "Zweispalitg",
"combined": "Kombiniert"
} }
} }

View File

@@ -24,6 +24,7 @@
"users": { "users": {
"title": "Benutzer", "title": "Benutzer",
"subtitle": "Verwaltung der Benutzer", "subtitle": "Verwaltung der Benutzer",
"noUsers": "Keine Benutzer gefunden.",
"createButton": "Benutzer erstellen" "createButton": "Benutzer erstellen"
}, },
"singleUser": { "singleUser": {

View File

@@ -10,7 +10,8 @@
}, },
"groups": { "groups": {
"title": "Groups", "title": "Groups",
"subtitle": "Create, read, update and delete groups" "subtitle": "Create, read, update and delete groups",
"noGroups": "No groups found."
}, },
"singleGroup": { "singleGroup": {
"errorTitle": "Error", "errorTitle": "Error",

View File

@@ -11,7 +11,10 @@
"validation": { "validation": {
"namespace-invalid": "The repository namespace is invalid", "namespace-invalid": "The repository namespace is invalid",
"name-invalid": "The repository name is invalid", "name-invalid": "The repository name is invalid",
"contact-invalid": "Contact must be a valid mail address" "contact-invalid": "Contact must be a valid mail address",
"branch": {
"nameInvalid": "The branch name is invalid"
}
}, },
"help": { "help": {
"namespaceHelpText": "The namespace of the repository. This name will be part of the repository url.", "namespaceHelpText": "The namespace of the repository. This name will be part of the repository url.",
@@ -26,6 +29,7 @@
"menu": { "menu": {
"navigationLabel": "Repository Navigation", "navigationLabel": "Repository Navigation",
"informationNavLink": "Information", "informationNavLink": "Information",
"branchesNavLink": "Branches",
"historyNavLink": "Commits", "historyNavLink": "Commits",
"sourcesNavLink": "Sources", "sourcesNavLink": "Sources",
"settingsNavLink": "Settings", "settingsNavLink": "Settings",
@@ -36,15 +40,39 @@
"overview": { "overview": {
"title": "Repositories", "title": "Repositories",
"subtitle": "Overview of available repositories", "subtitle": "Overview of available repositories",
"noRepositories": "No repositories found.",
"createButton": "Create Repository" "createButton": "Create Repository"
}, },
"create": { "create": {
"title": "Create Repository", "title": "Create Repository",
"subtitle": "Create a new repository" "subtitle": "Create a new repository"
}, },
"branches": {
"overview": {
"title": "Overview of all branches",
"noBranches": "No branches found.",
"createButton": "Create Branch"
},
"table": {
"branches": "Branches"
},
"create": {
"title": "Create Branch",
"source": "Source Branch",
"name": "Name",
"submit": "Create Branch"
}
},
"branch": {
"name": "Name:",
"commits": "Commits",
"sources": "Sources",
"defaultTag": "Default"
},
"changesets": { "changesets": {
"errorTitle": "Error", "errorTitle": "Error",
"errorSubtitle": "Could not fetch changesets", "errorSubtitle": "Could not fetch changesets",
"noChangesets": "No changesets found for this branch.",
"branchSelectorLabel": "Branches" "branchSelectorLabel": "Branches"
}, },
"changeset": { "changeset": {
@@ -83,7 +111,8 @@
"lastModified": "Last modified", "lastModified": "Last modified",
"description": "Description", "description": "Description",
"size": "Size" "size": "Size"
} },
"noSources": "No sources found for this branch."
}, },
"permission": { "permission": {
"title": "Edit Permissions", "title": "Edit Permissions",
@@ -155,6 +184,8 @@
"modify": "modified", "modify": "modified",
"rename": "renamed", "rename": "renamed",
"copy": "copied" "copy": "copied"
} },
"sideBySide": "side-by-side",
"combined": "combined"
} }
} }

View File

@@ -24,6 +24,7 @@
"users": { "users": {
"title": "Users", "title": "Users",
"subtitle": "Create, read, update and delete users", "subtitle": "Create, read, update and delete users",
"noUsers": "No users found.",
"createButton": "Create User" "createButton": "Create User"
}, },
"singleUser": { "singleUser": {

View File

@@ -52,7 +52,7 @@ class Index extends Component<Props, State> {
}; };
render() { render() {
const { indexResources, loading, error, t } = this.props; const { indexResources, loading, error } = this.props;
const { pluginsLoaded } = this.state; const { pluginsLoaded } = this.state;
if (error) { if (error) {

View File

@@ -8,7 +8,7 @@ import {
logout, logout,
isAuthenticated, isAuthenticated,
isLogoutPending, isLogoutPending,
getLogoutFailure getLogoutFailure, isRedirecting
} from "../modules/auth"; } from "../modules/auth";
import { Loading, ErrorPage } from "@scm-manager/ui-components"; import { Loading, ErrorPage } from "@scm-manager/ui-components";
import { getLogoutLink } from "../modules/indexResource"; import { getLogoutLink } from "../modules/indexResource";
@@ -16,6 +16,7 @@ import { getLogoutLink } from "../modules/indexResource";
type Props = { type Props = {
authenticated: boolean, authenticated: boolean,
loading: boolean, loading: boolean,
redirecting: boolean,
error: Error, error: Error,
logoutLink: string, logoutLink: string,
@@ -32,7 +33,7 @@ class Logout extends React.Component<Props> {
} }
render() { render() {
const { authenticated, loading, error, t } = this.props; const { authenticated, redirecting, loading, error, t } = this.props;
if (error) { if (error) {
return ( return (
<ErrorPage <ErrorPage
@@ -41,7 +42,7 @@ class Logout extends React.Component<Props> {
error={error} error={error}
/> />
); );
} else if (loading || authenticated) { } else if (loading || authenticated || redirecting) {
return <Loading />; return <Loading />;
} else { } else {
return <Redirect to="/login" />; return <Redirect to="/login" />;
@@ -52,11 +53,13 @@ class Logout extends React.Component<Props> {
const mapStateToProps = state => { const mapStateToProps = state => {
const authenticated = isAuthenticated(state); const authenticated = isAuthenticated(state);
const loading = isLogoutPending(state); const loading = isLogoutPending(state);
const redirecting = isRedirecting(state);
const error = getLogoutFailure(state); const error = getLogoutFailure(state);
const logoutLink = getLogoutLink(state); const logoutLink = getLogoutLink(state);
return { return {
authenticated, authenticated,
loading, loading,
redirecting,
error, error,
logoutLink logoutLink
}; };

View File

@@ -61,13 +61,11 @@ class PluginLoader extends React.Component<Props, State> {
} }
return promises.reduce((chain, current) => { return promises.reduce((chain, current) => {
return chain.then(chainResults => { return chain.then(chainResults => {
return current.then(currentResult => [...chainResults, currentResult]) return current.then(currentResult => [...chainResults, currentResult]);
} });
);
}, Promise.resolve([])); }, Promise.resolve([]));
}; };
loadPlugin = (plugin: Plugin) => { loadPlugin = (plugin: Plugin) => {
this.setState({ this.setState({
message: `loading ${plugin.name}` message: `loading ${plugin.name}`

View File

@@ -19,7 +19,7 @@ import namespaceStrategies from "./config/modules/namespaceStrategies";
import indexResources from "./modules/indexResource"; import indexResources from "./modules/indexResource";
import type { BrowserHistory } from "history/createBrowserHistory"; import type { BrowserHistory } from "history/createBrowserHistory";
import branches from "./repos/modules/branches"; import branches from "./repos/branches/modules/branches";
function createReduxStore(history: BrowserHistory) { function createReduxStore(history: BrowserHistory) {
const composeEnhancers = const composeEnhancers =

View File

@@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Group } from "@scm-manager/ui-types"; import type { Group } from "@scm-manager/ui-types";
import { Checkbox } from "@scm-manager/ui-components" import { Checkbox } from "@scm-manager/ui-components";
type Props = { type Props = {
group: Group group: Group

View File

@@ -19,6 +19,7 @@ import {
Page, Page,
PageActions, PageActions,
Button, Button,
Notification,
LinkPaginator, LinkPaginator,
getPageFromMatch getPageFromMatch
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
@@ -64,6 +65,13 @@ class Groups extends React.Component<Props, State> {
this.setState({ page: page }); this.setState({ page: page });
} }
onPageChange = (link: string) => {
this.props.fetchGroupsByLink(link);
};
/**
* reflect page transitions in the uri
*/
componentDidUpdate = (prevProps: Props) => { componentDidUpdate = (prevProps: Props) => {
const { list, page, location, fetchGroupsByPage, groupLink } = this.props; const { list, page, location, fetchGroupsByPage, groupLink } = this.props;
if (list && page) { if (list && page) {
@@ -89,14 +97,26 @@ class Groups extends React.Component<Props, State> {
history.push("/groups/?q=" + filter); history.push("/groups/?q=" + filter);
}} }}
> >
<GroupTable groups={groups} /> {this.renderGroupTable()}
{this.renderPaginator()}
{this.renderCreateButton()} {this.renderCreateButton()}
{this.renderPageActionCreateButton()} {this.renderPageActionCreateButton()}
</Page> </Page>
); );
} }
renderGroupTable() {
const { groups, t } = this.props;
if (groups && groups.length > 0) {
return (
<>
<GroupTable groups={groups} />
{this.renderPaginator()}
</>
);
}
return <Notification type="info">{t("groups.noGroups")}</Notification>;
}
renderPaginator = () => { renderPaginator = () => {
const { list, page } = this.props; const { list, page } = this.props;
if (list) { if (list) {

View File

@@ -29,6 +29,7 @@ export const LOGOUT = "scm/auth/LOGOUT";
export const LOGOUT_PENDING = `${LOGOUT}_${types.PENDING_SUFFIX}`; export const LOGOUT_PENDING = `${LOGOUT}_${types.PENDING_SUFFIX}`;
export const LOGOUT_SUCCESS = `${LOGOUT}_${types.SUCCESS_SUFFIX}`; export const LOGOUT_SUCCESS = `${LOGOUT}_${types.SUCCESS_SUFFIX}`;
export const LOGOUT_FAILURE = `${LOGOUT}_${types.FAILURE_SUFFIX}`; export const LOGOUT_FAILURE = `${LOGOUT}_${types.FAILURE_SUFFIX}`;
export const LOGOUT_REDIRECT = `${LOGOUT}_REDIRECT`;
// Reducer // Reducer
@@ -54,6 +55,13 @@ export default function reducer(
case LOGOUT_SUCCESS: case LOGOUT_SUCCESS:
return initialState; return initialState;
case LOGOUT_REDIRECT: {
// we keep the current state until we are redirected to the new page
return {
...state,
redirecting: true
};
}
default: default:
return state; return state;
} }
@@ -89,10 +97,16 @@ export const logoutPending = () => {
export const logoutSuccess = () => { export const logoutSuccess = () => {
return { return {
type: LOGOUT_SUCCESS type: LOGOUT_SUCCESS,
}; };
}; };
export const redirectAfterLogout = () => {
return {
type: LOGOUT_REDIRECT
}
};
export const logoutFailure = (error: Error) => { export const logoutFailure = (error: Error) => {
return { return {
type: LOGOUT_FAILURE, type: LOGOUT_FAILURE,
@@ -130,11 +144,9 @@ export const fetchMeFailure = (error: Error) => {
// side effects // side effects
const callFetchMe = (link: string): Promise<Me> => { const callFetchMe = (link: string): Promise<Me> => {
return apiClient return apiClient.get(link).then(response => {
.get(link) return response.json();
.then(response => { });
return response.json();
});
}; };
export const login = ( export const login = (
@@ -192,11 +204,28 @@ export const logout = (link: string) => {
dispatch(logoutPending()); dispatch(logoutPending());
return apiClient return apiClient
.delete(link) .delete(link)
.then(() => { .then(response => {
dispatch(logoutSuccess()); return response.status === 200
? response.json()
: new Promise(function(resolve) {
resolve();
});
}) })
.then(() => { .then(json => {
dispatch(fetchIndexResources()); let fetchIndex = true;
if (json && json.logoutRedirect) {
dispatch(redirectAfterLogout());
window.location.assign(json.logoutRedirect);
fetchIndex = false;
} else {
dispatch(logoutSuccess());
}
return fetchIndex;
})
.then((fetchIndex: boolean) => {
if (fetchIndex) {
dispatch(fetchIndexResources());
}
}) })
.catch(error => { .catch(error => {
dispatch(logoutFailure(error)); dispatch(logoutFailure(error));
@@ -244,3 +273,8 @@ export const isLogoutPending = (state: Object) => {
export const getLogoutFailure = (state: Object) => { export const getLogoutFailure = (state: Object) => {
return getFailure(state, LOGOUT); return getFailure(state, LOGOUT);
}; };
export const isRedirecting = (state: Object) => {
return !!stateAuth(state).redirecting;
};

View File

@@ -26,7 +26,7 @@ import reducer, {
FETCH_ME, FETCH_ME,
LOGOUT, LOGOUT,
getLoginFailure, getLoginFailure,
getLogoutFailure getLogoutFailure, isRedirecting, LOGOUT_REDIRECT, redirectAfterLogout,
} from "./auth"; } from "./auth";
import configureMockStore from "redux-mock-store"; import configureMockStore from "redux-mock-store";
@@ -70,6 +70,17 @@ describe("auth reducer", () => {
expect(state.authenticated).toBeUndefined(); expect(state.authenticated).toBeUndefined();
}); });
it("should keep state and set redirecting to true", () => {
const initialState = {
authenticated: true,
me
};
const state = reducer(initialState, redirectAfterLogout());
expect(state.me).toBe(initialState.me);
expect(state.authenticated).toBe(initialState.authenticated);
expect(state.redirecting).toBe(true);
});
it("should set state authenticated and me after login", () => { it("should set state authenticated and me after login", () => {
const state = reducer(undefined, loginSuccess(me)); const state = reducer(undefined, loginSuccess(me));
expect(state.me).toBe(me); expect(state.me).toBe(me);
@@ -224,6 +235,41 @@ describe("auth actions", () => {
}); });
}); });
it("should dispatch logout success and redirect", () => {
fetchMock.deleteOnce("/api/v2/auth/access_token", {
status: 200,
body: { logoutRedirect: "http://example.com/cas/logout" }
});
fetchMock.getOnce("/api/v2/me", {
status: 401
});
fetchMock.getOnce("/api/v2/", {
_links: {
login: {
login: "/login"
}
}
});
window.location.assign = jest.fn();
const expectedActions = [
{ type: LOGOUT_PENDING },
{ type: LOGOUT_REDIRECT }
];
const store = mockStore({});
return store.dispatch(logout("/auth/access_token")).then(() => {
expect(window.location.assign.mock.calls[0][0]).toBe(
"http://example.com/cas/logout"
);
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch logout failure", () => { it("should dispatch logout failure", () => {
fetchMock.deleteOnce("/api/v2/auth/access_token", { fetchMock.deleteOnce("/api/v2/auth/access_token", {
status: 500 status: 500
@@ -307,4 +353,16 @@ describe("auth selectors", () => {
it("should return unknown, if failure state is not set for LOGOUT", () => { it("should return unknown, if failure state is not set for LOGOUT", () => {
expect(getLogoutFailure({})).toBeUndefined(); expect(getLogoutFailure({})).toBeUndefined();
}); });
it("should return false, if redirecting is not set", () => {
expect(isRedirecting({})).toBe(false);
});
it("should return false, if redirecting is false", () => {
expect(isRedirecting({auth: { redirecting: false }})).toBe(false);
});
it("should return true, if redirecting is true", () => {
expect(isRedirecting({auth: { redirecting: true }})).toBe(true);
});
}); });

View File

@@ -0,0 +1,49 @@
//@flow
import React from "react";
import type { Repository, Branch } from "@scm-manager/ui-types";
import { ButtonGroup, Button } from "@scm-manager/ui-components";
import { translate } from "react-i18next";
type Props = {
repository: Repository,
branch: Branch,
// context props
t: string => string
};
class BranchButtonGroup extends React.Component<Props> {
render() {
const { repository, branch, t } = this.props;
const changesetLink = `/repo/${repository.namespace}/${
repository.name
}/branch/${encodeURIComponent(branch.name)}/changesets/`;
const sourcesLink = `/repo/${repository.namespace}/${
repository.name
}/sources/${encodeURIComponent(branch.name)}/`;
return (
<ButtonGroup>
<Button link={changesetLink}>
<span className="icon">
<i className="fas fa-exchange-alt" />
</span>
<span className="is-hidden-mobile is-hidden-tablet-only">
{t("branch.commits")}
</span>
</Button>
<Button link={sourcesLink}>
<span className="icon">
<i className="fas fa-code" />
</span>
<span className="is-hidden-mobile is-hidden-tablet-only">
{t("branch.sources")}
</span>
</Button>
</ButtonGroup>
);
}
}
export default translate("repos")(BranchButtonGroup);

View File

@@ -0,0 +1,33 @@
//@flow
import React from "react";
import type { Repository, Branch } from "@scm-manager/ui-types";
import { translate } from "react-i18next";
import BranchButtonGroup from "./BranchButtonGroup";
import DefaultBranchTag from "./DefaultBranchTag";
type Props = {
repository: Repository,
branch: Branch,
// context props
t: string => string
};
class BranchDetail extends React.Component<Props> {
render() {
const { repository, branch, t } = this.props;
return (
<div className="media">
<div className="media-content subtitle">
<strong>{t("branch.name")}</strong> {branch.name}{" "}
<DefaultBranchTag defaultBranch={branch.defaultBranch} />
</div>
<div className="media-right">
<BranchButtonGroup repository={repository} branch={branch} />
</div>
</div>
);
}
}
export default translate("repos")(BranchDetail);

View File

@@ -0,0 +1,125 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { Repository, Branch, BranchRequest } from "@scm-manager/ui-types";
import {
Select,
InputField,
SubmitButton,
validation as validator
} from "@scm-manager/ui-components";
import { orderBranches } from "../util/orderBranches";
type Props = {
submitForm: BranchRequest => void,
repository: Repository,
branches: Branch[],
loading?: boolean,
transmittedName?: string,
disabled?: boolean,
t: string => string
};
type State = {
source?: string,
name?: string,
nameValidationError: boolean
};
class BranchForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
nameValidationError: false,
name: props.transmittedName
};
}
isFalsy(value) {
return !value;
}
isValid = () => {
const { source, name } = this.state;
return !(
this.state.nameValidationError ||
this.isFalsy(source) ||
this.isFalsy(name)
);
};
submit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.submitForm({
name: this.state.name,
parent: this.state.source
});
}
};
render() {
const { t, branches, loading, transmittedName, disabled } = this.props;
const { name } = this.state;
orderBranches(branches);
const options = branches.map(branch => ({
label: branch.name,
value: branch.name
}));
return (
<>
<form onSubmit={this.submit}>
<div className="columns">
<div className="column">
<Select
name="source"
label={t("branches.create.source")}
options={options}
onChange={this.handleSourceChange}
loading={loading}
disabled={disabled}
/>
<InputField
name="name"
label={t("branches.create.name")}
onChange={this.handleNameChange}
value={name ? name : ""}
validationError={this.state.nameValidationError}
errorMessage={t("validation.branch.nameInvalid")}
disabled={!!transmittedName || disabled}
/>
</div>
</div>
<div className="columns">
<div className="column">
<SubmitButton
disabled={disabled || !this.isValid()}
loading={loading}
label={t("branches.create.submit")}
/>
</div>
</div>
</form>
</>
);
}
handleSourceChange = (source: string) => {
this.setState({
...this.state,
source
});
};
handleNameChange = (name: string) => {
this.setState({
nameValidationError: !validator.isNameValid(name),
...this.state,
name
});
};
}
export default translate("repos")(BranchForm);

View File

@@ -0,0 +1,32 @@
// @flow
import React from "react";
import { Link } from "react-router-dom";
import type { Branch } from "@scm-manager/ui-types";
import DefaultBranchTag from "./DefaultBranchTag";
type Props = {
baseUrl: string,
branch: Branch
};
class BranchRow extends React.Component<Props> {
renderLink(to: string, label: string, defaultBranch?: boolean) {
return (
<Link to={to}>
{label} <DefaultBranchTag defaultBranch={defaultBranch} />
</Link>
);
}
render() {
const { baseUrl, branch } = this.props;
const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`;
return (
<tr>
<td>{this.renderLink(to, branch.name, branch.defaultBranch)}</td>
</tr>
);
}
}
export default BranchRow;

View File

@@ -0,0 +1,40 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import BranchRow from "./BranchRow";
import type { Branch } from "@scm-manager/ui-types";
type Props = {
baseUrl: string,
t: string => string,
branches: Branch[]
};
class BranchTable extends React.Component<Props> {
render() {
const { t } = this.props;
return (
<table className="card-table table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("branches.table.branches")}</th>
</tr>
</thead>
<tbody>{this.renderRow()}</tbody>
</table>
);
}
renderRow() {
const { baseUrl, branches } = this.props;
let rowContent = null;
if (branches) {
rowContent = branches.map((branch, index) => {
return <BranchRow key={index} baseUrl={baseUrl} branch={branch} />;
});
}
return rowContent;
}
}
export default translate("repos")(BranchTable);

View File

@@ -0,0 +1,32 @@
// @flow
import React from "react";
import BranchDetail from "./BranchDetail";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Repository, Branch } from "@scm-manager/ui-types";
type Props = {
repository: Repository,
branch: Branch
};
class BranchView extends React.Component<Props> {
render() {
const { repository, branch } = this.props;
return (
<div>
<BranchDetail repository={repository} branch={branch} />
<hr />
<div className="content">
<ExtensionPoint
name="repos.branch-details.information"
renderAll={true}
props={{ repository, branch }}
/>
</div>
</div>
);
}
}
export default BranchView;

View File

@@ -0,0 +1,35 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import classNames from "classnames";
import { translate } from "react-i18next";
type Props = {
defaultBranch?: boolean,
classes: any,
t: string => string
};
const styles = {
tag: {
marginLeft: "0.75rem",
verticalAlign: "inherit"
}
};
class DefaultBranchTag extends React.Component<Props> {
render() {
const { defaultBranch, classes, t } = this.props;
if (defaultBranch) {
return (
<span className={classNames("tag is-dark", classes.tag)}>
{t("branch.defaultTag")}
</span>
);
}
return null;
}
}
export default injectSheet(styles)(translate("repos")(DefaultBranchTag));

View File

@@ -0,0 +1,120 @@
//@flow
import React from "react";
import BranchView from "../components/BranchView";
import { connect } from "react-redux";
import { Redirect, Route, Switch, withRouter } from "react-router-dom";
import type { Repository, Branch } from "@scm-manager/ui-types";
import {
fetchBranch,
getBranch,
getFetchBranchFailure,
isFetchBranchPending
} from "../modules/branches";
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
import type { History } from "history";
import { NotFoundError } from "@scm-manager/ui-components";
import queryString from "query-string";
type Props = {
repository: Repository,
branchName: string,
branch: Branch,
loading: boolean,
error?: Error,
// context props
history: History,
match: any,
location: any,
// dispatch functions
fetchBranch: (repository: Repository, branchName: string) => void
};
class BranchRoot extends React.Component<Props> {
componentDidMount() {
const { fetchBranch, repository, branchName } = this.props;
fetchBranch(repository, branchName);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { repository, branch, loading, error, match, location } = this.props;
const url = this.matchedUrl();
if (error) {
if (
error instanceof NotFoundError &&
queryString.parse(location.search).create === "true"
) {
return (
<Redirect
to={`/repo/${repository.namespace}/${
repository.name
}/branches/create?name=${match.params.branch}`}
/>
);
}
return <ErrorNotification error={error} />;
}
if (loading || !branch) {
return <Loading />;
}
return (
<Switch>
<Redirect exact from={url} to={`${url}/info`} />
<Route
path={`${url}/info`}
component={() => (
<BranchView repository={repository} branch={branch} />
)}
/>
</Switch>
);
}
}
const mapStateToProps = (state, ownProps) => {
const { repository } = ownProps;
const branchName = decodeURIComponent(ownProps.match.params.branch);
const branch = getBranch(state, repository, branchName);
const loading = isFetchBranchPending(state, repository, branchName);
const error = getFetchBranchFailure(state, repository, branchName);
return {
repository,
branchName,
branch,
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
fetchBranch: (repository: Repository, branchName: string) => {
dispatch(fetchBranch(repository, branchName));
}
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(BranchRoot)
);

View File

@@ -0,0 +1,122 @@
// @flow
import React from "react";
import {
fetchBranches,
getBranches,
getFetchBranchesFailure,
isFetchBranchesPending,
isPermittedToCreateBranches
} from "../modules/branches";
import { orderBranches } from "../util/orderBranches";
import { connect } from "react-redux";
import type { Branch, Repository } from "@scm-manager/ui-types";
import { compose } from "redux";
import { translate } from "react-i18next";
import { withRouter } from "react-router-dom";
import {
CreateButton,
ErrorNotification,
Loading,
Notification,
Subtitle
} from "@scm-manager/ui-components";
import BranchTable from "../components/BranchTable";
type Props = {
repository: Repository,
baseUrl: string,
loading: boolean,
error: Error,
branches: Branch[],
// dispatch props
showCreateButton: boolean,
fetchBranches: Repository => void,
// Context props
history: any,
match: any,
t: string => string
};
class BranchesOverview extends React.Component<Props> {
componentDidMount() {
const { fetchBranches, repository } = this.props;
fetchBranches(repository);
}
render() {
const { loading, error, branches, t } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (!branches || loading) {
return <Loading />;
}
return (
<>
<Subtitle subtitle={t("branches.overview.title")} />
{this.renderBranchesTable()}
{this.renderCreateButton()}
</>
);
}
renderBranchesTable() {
const { baseUrl, branches, t } = this.props;
if (branches && branches.length > 0) {
orderBranches(branches);
return <BranchTable baseUrl={baseUrl} branches={branches} />;
}
return <Notification type="info">{t("branches.overview.noBranches")}</Notification>;
}
renderCreateButton() {
const { showCreateButton, t } = this.props;
if (showCreateButton) {
return (
<CreateButton
label={t("branches.overview.createButton")}
link="./create"
/>
);
}
return null;
}
}
const mapStateToProps = (state, ownProps) => {
const { repository } = ownProps;
const loading = isFetchBranchesPending(state, repository);
const error = getFetchBranchesFailure(state, repository);
const branches = getBranches(state, repository);
const showCreateButton = isPermittedToCreateBranches(state, repository);
return {
repository,
loading,
error,
branches,
showCreateButton
};
};
const mapDispatchToProps = dispatch => {
return {
fetchBranches: (repository: Repository) => {
dispatch(fetchBranches(repository));
}
};
};
export default compose(
translate("repos"),
withRouter,
connect(
mapStateToProps,
mapDispatchToProps
)
)(BranchesOverview);

View File

@@ -0,0 +1,150 @@
//@flow
import React from "react";
import {
ErrorNotification,
Loading,
Subtitle
} from "@scm-manager/ui-components";
import { translate } from "react-i18next";
import BranchForm from "../components/BranchForm";
import type { Repository, Branch, BranchRequest } from "@scm-manager/ui-types";
import {
fetchBranches,
getBranches,
getBranchCreateLink,
createBranch,
createBranchReset,
isCreateBranchPending,
getCreateBranchFailure,
isFetchBranchesPending,
getFetchBranchesFailure
} from "../modules/branches";
import type { History } from "history";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import queryString from "query-string";
type Props = {
loading?: boolean,
error?: Error,
repository: Repository,
branches: Branch[],
createBranchesLink: string,
isPermittedToCreateBranches: boolean,
// dispatcher functions
fetchBranches: Repository => void,
createBranch: (
createLink: string,
repository: Repository,
branch: BranchRequest,
callback?: (Branch) => void
) => void,
resetForm: Repository => void,
// context objects
t: string => string,
history: History,
location: any
};
class CreateBranch extends React.Component<Props> {
componentDidMount() {
const { fetchBranches, repository } = this.props;
fetchBranches(repository);
this.props.resetForm(repository);
}
branchCreated = (branch: Branch) => {
const { history, repository } = this.props;
history.push(
`/repo/${repository.namespace}/${
repository.name
}/branch/${encodeURIComponent(branch.name)}/info`
);
};
createBranch = (branch: BranchRequest) => {
this.props.createBranch(
this.props.createBranchesLink,
this.props.repository,
branch,
newBranch => this.branchCreated(newBranch)
);
};
transmittedName = (url: string) => {
const params = queryString.parse(url);
return params.name;
};
render() {
const { t, loading, error, repository, branches, createBranchesLink, location } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading || !branches) {
return <Loading />;
}
return (
<>
<Subtitle subtitle={t("branches.create.title")} />
<BranchForm
submitForm={branchRequest => this.createBranch(branchRequest)}
loading={loading}
repository={repository}
branches={branches}
transmittedName={this.transmittedName(location.search)}
disabled={!createBranchesLink}
/>
</>
);
}
}
const mapDispatchToProps = dispatch => {
return {
fetchBranches: (repository: Repository) => {
dispatch(fetchBranches(repository));
},
createBranch: (
createLink: string,
repository: Repository,
branchRequest: BranchRequest,
callback?: (newBranch: Branch) => void
) => {
dispatch(createBranch(createLink, repository, branchRequest, callback));
},
resetForm: (repository: Repository) => {
dispatch(createBranchReset(repository));
}
};
};
const mapStateToProps = (state, ownProps) => {
const { repository } = ownProps;
const loading =
isFetchBranchesPending(state, repository) ||
isCreateBranchPending(state, repository);
const error =
getFetchBranchesFailure(state, repository) || getCreateBranchFailure(state);
const branches = getBranches(state, repository);
const createBranchesLink = getBranchCreateLink(state, repository);
return {
repository,
loading,
error,
branches,
createBranchesLink
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(translate("repos")(CreateBranch))
);

View File

@@ -0,0 +1,361 @@
// @flow
import {
FAILURE_SUFFIX,
PENDING_SUFFIX,
SUCCESS_SUFFIX,
RESET_SUFFIX
} from "../../../modules/types";
import { apiClient } from "@scm-manager/ui-components";
import type {
Action,
Branch,
BranchRequest,
Repository
} from "@scm-manager/ui-types";
import { isPending } from "../../../modules/pending";
import { getFailure } from "../../../modules/failure";
import memoizeOne from 'memoize-one';
export const FETCH_BRANCHES = "scm/repos/FETCH_BRANCHES";
export const FETCH_BRANCHES_PENDING = `${FETCH_BRANCHES}_${PENDING_SUFFIX}`;
export const FETCH_BRANCHES_SUCCESS = `${FETCH_BRANCHES}_${SUCCESS_SUFFIX}`;
export const FETCH_BRANCHES_FAILURE = `${FETCH_BRANCHES}_${FAILURE_SUFFIX}`;
export const FETCH_BRANCH = "scm/repos/FETCH_BRANCH";
export const FETCH_BRANCH_PENDING = `${FETCH_BRANCH}_${PENDING_SUFFIX}`;
export const FETCH_BRANCH_SUCCESS = `${FETCH_BRANCH}_${SUCCESS_SUFFIX}`;
export const FETCH_BRANCH_FAILURE = `${FETCH_BRANCH}_${FAILURE_SUFFIX}`;
export const CREATE_BRANCH = "scm/repos/CREATE_BRANCH";
export const CREATE_BRANCH_PENDING = `${CREATE_BRANCH}_${PENDING_SUFFIX}`;
export const CREATE_BRANCH_SUCCESS = `${CREATE_BRANCH}_${SUCCESS_SUFFIX}`;
export const CREATE_BRANCH_FAILURE = `${CREATE_BRANCH}_${FAILURE_SUFFIX}`;
export const CREATE_BRANCH_RESET = `${CREATE_BRANCH}_${RESET_SUFFIX}`;
const CONTENT_TYPE_BRANCH_REQUEST =
"application/vnd.scmm-branchRequest+json;v=2";
// Fetching branches
export function fetchBranches(repository: Repository) {
if (!repository._links.branches) {
return {
type: FETCH_BRANCHES_SUCCESS,
payload: { repository, data: {} },
itemId: createKey(repository)
};
}
return function(dispatch: any) {
dispatch(fetchBranchesPending(repository));
return apiClient
.get(repository._links.branches.href)
.then(response => response.json())
.then(data => {
dispatch(fetchBranchesSuccess(data, repository));
})
.catch(error => {
dispatch(fetchBranchesFailure(repository, error));
});
};
}
export function fetchBranch(repository: Repository, name: string) {
let link = repository._links.branches.href;
if (!link.endsWith("/")) {
link += "/";
}
link += encodeURIComponent(name);
return function(dispatch: any) {
dispatch(fetchBranchPending(repository, name));
return apiClient
.get(link)
.then(response => {
return response.json();
})
.then(data => {
dispatch(fetchBranchSuccess(repository, data));
})
.catch(error => {
dispatch(fetchBranchFailure(repository, name, error));
});
};
}
// create branch
export function createBranch(
link: string,
repository: Repository,
branchRequest: BranchRequest,
callback?: (branch: Branch) => void
) {
return function(dispatch: any) {
dispatch(createBranchPending(repository));
return apiClient
.post(link, branchRequest, CONTENT_TYPE_BRANCH_REQUEST)
.then(response => response.headers.get("Location"))
.then(location => apiClient.get(location))
.then(response => response.json())
.then(branch => {
dispatch(createBranchSuccess(repository));
if (callback) {
callback(branch);
}
})
.catch(error => dispatch(createBranchFailure(repository, error)));
};
}
// Selectors
function collectBranches(repoState) {
return repoState.list._embedded.branches.map(
name => repoState.byName[name]
);
}
const memoizedBranchCollector = memoizeOne(collectBranches);
export function getBranches(state: Object, repository: Repository) {
const repoState = getRepoState(state, repository);
if (repoState && repoState.list) {
return memoizedBranchCollector(repoState);
}
}
export function getBranchCreateLink(state: Object, repository: Repository) {
const repoState = getRepoState(state, repository);
if (repoState && repoState.list && repoState.list._links && repoState.list._links.create) {
return repoState.list._links.create.href;
}
}
function getRepoState(state: Object, repository: Repository) {
const key = createKey(repository);
const repoState = state.branches[key];
if (repoState && repoState.byName) {
return repoState;
}
}
export const isPermittedToCreateBranches = (
state: Object,
repository: Repository
): boolean => {
const repoState = getRepoState(state, repository);
return !!(
repoState &&
repoState.list &&
repoState.list._links &&
repoState.list._links.create
);
};
export function getBranch(
state: Object,
repository: Repository,
name: string
): ?Branch {
const repoState = getRepoState(state, repository);
if (repoState) {
return repoState.byName[name];
}
}
// Action creators
export function isFetchBranchesPending(
state: Object,
repository: Repository
): boolean {
return isPending(state, FETCH_BRANCHES, createKey(repository));
}
export function getFetchBranchesFailure(state: Object, repository: Repository) {
return getFailure(state, FETCH_BRANCHES, createKey(repository));
}
export function fetchBranchesPending(repository: Repository) {
return {
type: FETCH_BRANCHES_PENDING,
payload: { repository },
itemId: createKey(repository)
};
}
export function fetchBranchesSuccess(data: string, repository: Repository) {
return {
type: FETCH_BRANCHES_SUCCESS,
payload: { data, repository },
itemId: createKey(repository)
};
}
export function fetchBranchesFailure(repository: Repository, error: Error) {
return {
type: FETCH_BRANCHES_FAILURE,
payload: { error, repository },
itemId: createKey(repository)
};
}
export function isCreateBranchPending(state: Object, repository: Repository) {
return isPending(state, CREATE_BRANCH, createKey(repository));
}
export function getCreateBranchFailure(state: Object) {
return getFailure(state, CREATE_BRANCH);
}
export function createBranchPending(repository: Repository): Action {
return {
type: CREATE_BRANCH_PENDING,
payload: { repository },
itemId: createKey(repository)
};
}
export function createBranchSuccess(repository: Repository): Action {
return {
type: CREATE_BRANCH_SUCCESS,
payload: { repository },
itemId: createKey(repository)
};
}
export function createBranchFailure(
repository: Repository,
error: Error
): Action {
return {
type: CREATE_BRANCH_FAILURE,
payload: { repository, error },
itemId: createKey(repository)
};
}
export function createBranchReset(repository: Repository): Action {
return {
type: CREATE_BRANCH_RESET,
payload: { repository },
itemId: createKey(repository)
};
}
export function isFetchBranchPending(
state: Object,
repository: Repository,
name: string
) {
return isPending(state, FETCH_BRANCH, createKey(repository) + "/" + name);
}
export function getFetchBranchFailure(
state: Object,
repository: Repository,
name: string
) {
return getFailure(state, FETCH_BRANCH, createKey(repository) + "/" + name);
}
export function fetchBranchPending(
repository: Repository,
name: string
): Action {
return {
type: FETCH_BRANCH_PENDING,
payload: { repository, name },
itemId: createKey(repository) + "/" + name
};
}
export function fetchBranchSuccess(
repository: Repository,
branch: Branch
): Action {
return {
type: FETCH_BRANCH_SUCCESS,
payload: { repository, branch },
itemId: createKey(repository) + "/" + branch.name
};
}
export function fetchBranchFailure(
repository: Repository,
name: string,
error: Error
): Action {
return {
type: FETCH_BRANCH_FAILURE,
payload: { error, repository, name },
itemId: createKey(repository) + "/" + name
};
}
// Reducers
const reduceByBranchesSuccess = (state, payload) => {
const repository = payload.repository;
const response = payload.data;
const key = createKey(repository);
const repoState = state[key] || {};
const byName = repoState.byName || {};
repoState.byName = byName;
const branches = response._embedded.branches;
const names = branches.map(b => b.name);
response._embedded.branches = names;
for (let branch of branches) {
byName[branch.name] = branch;
}
return {
[key]: {
list: response,
byName
}
};
};
const reduceByBranchSuccess = (state, payload) => {
const repository = payload.repository;
const branch = payload.branch;
const key = createKey(repository);
const repoState = state[key] || {};
const byName = repoState.byName || {};
byName[branch.name] = branch;
repoState.byName = byName;
return {
...state,
[key]: repoState
};
};
export default function reducer(
state: {} = {},
action: Action = { type: "UNKNOWN" }
): Object {
if (!action.payload) {
return state;
}
const payload = action.payload;
switch (action.type) {
case FETCH_BRANCHES_SUCCESS:
return reduceByBranchesSuccess(state, payload);
case FETCH_BRANCH_SUCCESS:
return reduceByBranchSuccess(state, payload);
default:
return state;
}
}
function createKey(repository: Repository): string {
const { namespace, name } = repository;
return `${namespace}/${name}`;
}

View File

@@ -0,0 +1,471 @@
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_BRANCHES,
FETCH_BRANCHES_FAILURE,
FETCH_BRANCHES_PENDING,
FETCH_BRANCHES_SUCCESS,
FETCH_BRANCH_PENDING,
FETCH_BRANCH_SUCCESS,
FETCH_BRANCH_FAILURE,
CREATE_BRANCH,
CREATE_BRANCH_FAILURE,
CREATE_BRANCH_PENDING,
CREATE_BRANCH_SUCCESS,
fetchBranches,
fetchBranch,
fetchBranchSuccess,
getBranch,
getBranches,
getFetchBranchesFailure,
isFetchBranchesPending,
createBranch,
isCreateBranchPending,
getCreateBranchFailure,
isPermittedToCreateBranches
} from "./branches";
const namespace = "foo";
const name = "bar";
const key = namespace + "/" + name;
const repository = {
namespace: "foo",
name: "bar",
_links: {
branches: {
href: "http://scm/api/rest/v2/repositories/foo/bar/branches"
}
}
};
const branch1 = { name: "branch1", revision: "revision1" };
const branch2 = { name: "branch2", revision: "revision2" };
const branch3 = { name: "branch3", revision: "revision3" };
const branchRequest = { name: "newBranch", source: "master" };
const newBranch = { name: "newBranch", revision: "rev3" };
describe("branches", () => {
describe("fetch branches", () => {
const URL = "http://scm/api/rest/v2/repositories/foo/bar/branches";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should fetch branches", () => {
const collection = {};
fetchMock.getOnce(URL, "{}");
const expectedActions = [
{
type: FETCH_BRANCHES_PENDING,
payload: { repository },
itemId: key
},
{
type: FETCH_BRANCHES_SUCCESS,
payload: { data: collection, repository },
itemId: key
}
];
const store = mockStore({});
return store.dispatch(fetchBranches(repository)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail fetching branches on HTTP 500", () => {
const collection = {};
fetchMock.getOnce(URL, 500);
const expectedActions = [
{
type: FETCH_BRANCHES_PENDING,
payload: { repository },
itemId: key
},
{
type: FETCH_BRANCHES_FAILURE,
payload: { error: collection, repository },
itemId: key
}
];
const store = mockStore({});
return store.dispatch(fetchBranches(repository)).then(() => {
expect(store.getActions()[0]).toEqual(expectedActions[0]);
expect(store.getActions()[1].type).toEqual(FETCH_BRANCHES_FAILURE);
});
});
it("should successfully fetch single branch", () => {
fetchMock.getOnce(URL + "/branch1", branch1);
const store = mockStore({});
return store.dispatch(fetchBranch(repository, "branch1")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_BRANCH_PENDING);
expect(actions[1].type).toEqual(FETCH_BRANCH_SUCCESS);
expect(actions[1].payload).toBeDefined();
});
});
it("should fail fetching single branch on HTTP 500", () => {
fetchMock.getOnce(URL + "/branch2", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchBranch(repository, "branch2")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_BRANCH_PENDING);
expect(actions[1].type).toEqual(FETCH_BRANCH_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should create a branch successfully", () => {
//branchrequest answer
fetchMock.postOnce(URL, {
status: 201,
headers: {
location: URL + "/newBranch"
}
});
//branch answer
fetchMock.getOnce(URL + "/newBranch", newBranch);
const store = mockStore({});
return store
.dispatch(createBranch(URL, repository, branchRequest))
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_BRANCH_PENDING);
expect(actions[1].type).toEqual(CREATE_BRANCH_SUCCESS);
});
});
it("should call the callback with the branch from the location header", () => {
//branchrequest answer
fetchMock.postOnce(URL, {
status: 201,
headers: {
location: URL + "/newBranch"
}
});
//branch answer
fetchMock.getOnce(URL + "/newBranch", newBranch);
const store = mockStore({});
let receivedBranch = null;
const callback = branch => {
receivedBranch = branch;
};
return store
.dispatch(createBranch(URL, repository, branchRequest, callback))
.then(() => {
expect(receivedBranch).toEqual(newBranch);
});
});
it("should fail creating a branch on HTTP 500", () => {
fetchMock.postOnce(URL, {
status: 500
});
const store = mockStore({});
return store
.dispatch(createBranch(URL, repository, branchRequest))
.then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_BRANCH_PENDING);
expect(actions[1].type).toEqual(CREATE_BRANCH_FAILURE);
});
});
});
describe("branches reducer", () => {
const branches = {
_embedded: {
branches: [branch1, branch2]
},
_links: {
self: {
href: "/self"
},
create: {
href: "/create"
}
}
};
const action = {
type: FETCH_BRANCHES_SUCCESS,
payload: {
repository,
data: branches
}
};
it("should store the branches", () => {
const newState = reducer({}, action);
const repoState = newState["foo/bar"];
expect(repoState.list._links.create.href).toEqual("/create");
expect(repoState.list._embedded.branches).toEqual(["branch1", "branch2"]);
expect(repoState.byName.branch1).toEqual(branch1);
expect(repoState.byName.branch2).toEqual(branch2);
});
it("should store a single branch", () => {
const newState = reducer({}, fetchBranchSuccess(repository, branch1));
const repoState = newState["foo/bar"];
expect(repoState.list).toBeUndefined();
expect(repoState.byName.branch1).toEqual(branch1);
});
it("should add a single branch", () => {
const state = {
"foo/bar": {
list: {
_links: {},
_embedded: {
branches: ["branch1"]
}
},
byName: {
branch1: branch1
}
}
};
const newState = reducer(state, fetchBranchSuccess(repository, branch2));
const repoState = newState["foo/bar"];
const byName = repoState.byName;
expect(repoState.list._embedded.branches).toEqual(["branch1"]);
expect(byName.branch1).toEqual(branch1);
expect(byName.branch2).toEqual(branch2);
});
it("should not overwrite non related repositories", () => {
const state = {
"scm/core": {
byName: {
branch1: branch1
}
}
};
const newState = reducer(state, fetchBranchSuccess(repository, branch1));
const byName = newState["scm/core"].byName;
expect(byName.branch1).toEqual(branch1);
});
it("should overwrite existing branch", () => {
const state = {
"foo/bar": {
byName: {
branch1: {
name: "branch1",
revision: "xyz"
}
}
}
};
const newState = reducer(state, fetchBranchSuccess(repository, branch1));
const byName = newState["foo/bar"].byName;
expect(byName.branch1.revision).toEqual("revision1");
});
it("should not overwrite existing branches", () => {
const state = {
"foo/bar": {
byName: {
branch1,
branch2,
branch3
}
}
};
const newState = reducer(state, action);
expect(newState["foo/bar"].byName.branch1).toEqual(branch1);
expect(newState["foo/bar"].byName.branch2).toEqual(branch2);
expect(newState["foo/bar"].byName.branch3).toEqual(branch3);
});
});
describe("branch selectors", () => {
const error = new Error("Something went wrong");
const state = {
branches: {
"foo/bar": {
list: {
_links: {},
_embedded: {
branches: ["branch1", "branch2"]
}
},
byName: {
branch1: branch1,
branch2: branch2
}
}
}
};
it("should return true, when fetching branches is pending", () => {
const state = {
pending: {
[FETCH_BRANCHES + "/foo/bar"]: true
}
};
expect(isFetchBranchesPending(state, repository)).toBeTruthy();
});
it("should return branches", () => {
const branches = getBranches(state, repository);
expect(branches.length).toEqual(2);
expect(branches).toContain(branch1);
expect(branches).toContain(branch2);
});
it("should return always the same reference for branches", () => {
const one = getBranches(state, repository);
const two = getBranches(state, repository);
expect(one).toBe(two);
});
it("should not return cached reference, if branches have changed", () => {
const one = getBranches(state, repository);
const newState = {
branches: {
"foo/bar": {
list: {
_links: {},
_embedded: {
branches: ["branch2", "branch3"]
}
},
byName: {
branch2,
branch3
}
}
}
};
const two = getBranches(newState, repository);
expect(one).not.toBe(two);
expect(two).not.toContain(branch1);
expect(two).toContain(branch2);
expect(two).toContain(branch3);
});
it("should return undefined, if no branches for the repository available", () => {
const branches = getBranches({ branches: {} }, repository);
expect(branches).toBeUndefined();
});
it("should return single branch by name", () => {
const branch = getBranch(state, repository, "branch1");
expect(branch).toEqual(branch1);
});
it("should return same reference for single branch by name", () => {
const one = getBranch(state, repository, "branch1");
const two = getBranch(state, repository, "branch1");
expect(one).toBe(two);
});
it("should return undefined if branch does not exist", () => {
const branch = getBranch(state, repository, "branch42");
expect(branch).toBeUndefined();
});
it("should return true if the branches list contains the create link", () => {
const stateWithLink = {
branches: {
"foo/bar": {
...state.branches["foo/bar"],
list: {
...state.branches["foo/bar"].list,
_links: {
create: {
href: "http://create-it"
}
}
}
}
}
};
const permitted = isPermittedToCreateBranches(stateWithLink, repository);
expect(permitted).toBe(true);
});
it("should return false if the create link is missing", () => {
const permitted = isPermittedToCreateBranches(state, repository);
expect(permitted).toBe(false);
});
it("should return error if fetching branches failed", () => {
const state = {
failure: {
[FETCH_BRANCHES + "/foo/bar"]: error
}
};
expect(getFetchBranchesFailure(state, repository)).toEqual(error);
});
it("should return false if fetching branches did not fail", () => {
expect(getFetchBranchesFailure({}, repository)).toBeUndefined();
});
it("should return true if create branch is pending", () => {
const state = {
pending: {
[CREATE_BRANCH + "/foo/bar"]: true
}
};
expect(isCreateBranchPending(state, repository)).toBe(true);
});
it("should return false if create branch is not pending", () => {
const state = {
pending: {
[CREATE_BRANCH + "/foo/bar"]: false
}
};
expect(isCreateBranchPending(state, repository)).toBe(false);
});
it("should return error when create branch did fail", () => {
const state = {
failure: {
[CREATE_BRANCH]: error
}
};
expect(getCreateBranchFailure(state)).toEqual(error);
});
it("should return undefined when create branch did not fail", () => {
expect(getCreateBranchFailure({})).toBe(undefined);
});
});
});

View File

@@ -0,0 +1,32 @@
// @flow
// master, default should always be the first one,
// followed by develop the rest should be ordered by its name
import type {Branch} from "@scm-manager/ui-types";
export function orderBranches(branches: Branch[]) {
branches.sort((a, b) => {
if (a.defaultBranch && !b.defaultBranch) {
return -20;
} else if (!a.defaultBranch && b.defaultBranch) {
return 20;
} else if (a.name === "master" && b.name !== "master") {
return -10;
} else if (a.name !== "master" && b.name === "master") {
return 10;
} else if (a.name === "default" && b.name !== "default") {
return -10;
} else if (a.name !== "default" && b.name === "default") {
return 10;
} else if (a.name === "develop" && b.name !== "develop") {
return -5;
} else if (a.name !== "develop" && b.name === "develop") {
return 5;
} else if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
}
return 0;
});
}

View File

@@ -0,0 +1,51 @@
import { orderBranches } from "./orderBranches";
const branch1 = { name: "branch1", revision: "revision1" };
const branch2 = { name: "branch2", revision: "revision2" };
const branch3 = { name: "branch3", revision: "revision3", defaultBranch: true };
const defaultBranch = {
name: "default",
revision: "revision4",
defaultBranch: false
};
const developBranch = {
name: "develop",
revision: "revision5",
defaultBranch: false
};
const masterBranch = {
name: "master",
revision: "revision6",
defaultBranch: false
};
describe("order branches", () => {
it("should return branches", () => {
let branches = [branch1, branch2];
orderBranches(branches);
expect(branches).toEqual([branch1, branch2]);
});
it("should return defaultBranch first", () => {
let branches = [branch1, branch2, branch3];
orderBranches(branches);
expect(branches).toEqual([branch3, branch1, branch2]);
});
it("should order special branches as follows: master > default > develop", () => {
let branches = [defaultBranch, developBranch, masterBranch];
orderBranches(branches);
expect(branches).toEqual([masterBranch, defaultBranch, developBranch]);
});
it("should order special branches but starting with defaultBranch", () => {
let branches = [masterBranch, developBranch, defaultBranch, branch3];
orderBranches(branches);
expect(branches).toEqual([
branch3,
masterBranch,
defaultBranch,
developBranch
]);
});
});

View File

@@ -35,11 +35,23 @@ class RepositoryEntry extends React.Component<Props> {
return `/repo/${repository.namespace}/${repository.name}`; return `/repo/${repository.namespace}/${repository.name}`;
}; };
renderBranchesLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["branches"]) {
return (
<RepositoryEntryLink
iconClass="fas fa-code-branch fa-lg"
to={repositoryLink + "/branches"}
/>
);
}
return null;
};
renderChangesetsLink = (repository: Repository, repositoryLink: string) => { renderChangesetsLink = (repository: Repository, repositoryLink: string) => {
if (repository._links["changesets"]) { if (repository._links["changesets"]) {
return ( return (
<RepositoryEntryLink <RepositoryEntryLink
iconClass="fa-code-branch fa-lg" iconClass="fas fa-exchange-alt fa-lg"
to={repositoryLink + "/changesets"} to={repositoryLink + "/changesets"}
/> />
); );
@@ -102,6 +114,7 @@ class RepositoryEntry extends React.Component<Props> {
</div> </div>
<nav className="level is-mobile"> <nav className="level is-mobile">
<div className="level-left"> <div className="level-left">
{this.renderBranchesLink(repository, repositoryLink)}
{this.renderChangesetsLink(repository, repositoryLink)} {this.renderChangesetsLink(repository, repositoryLink)}
{this.renderSourcesLink(repository, repositoryLink)} {this.renderSourcesLink(repository, repositoryLink)}
{this.renderModifyLink(repository, repositoryLink)} {this.renderModifyLink(repository, repositoryLink)}

View File

@@ -22,9 +22,11 @@ import {
getPageFromMatch, getPageFromMatch,
LinkPaginator, LinkPaginator,
ChangesetList, ChangesetList,
Loading Loading,
Notification
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { compose } from "redux"; import { compose } from "redux";
import { translate } from "react-i18next";
type Props = { type Props = {
repository: Repository, repository: Repository,
@@ -41,7 +43,8 @@ type Props = {
fetchChangesets: (Repository, Branch, number) => void, fetchChangesets: (Repository, Branch, number) => void,
// context props // context props
match: any match: any,
t: string => string
}; };
class Changesets extends React.Component<Props> { class Changesets extends React.Component<Props> {
@@ -52,7 +55,7 @@ class Changesets extends React.Component<Props> {
} }
render() { render() {
const { changesets, loading, error } = this.props; const { changesets, loading, error, t } = this.props;
if (error) { if (error) {
return <ErrorNotification error={error} />; return <ErrorNotification error={error} />;
@@ -63,7 +66,13 @@ class Changesets extends React.Component<Props> {
} }
if (!changesets || changesets.length === 0) { if (!changesets || changesets.length === 0) {
return null; return (
<div className="panel-block">
<Notification type="info">
{t("changesets.noChangesets")}
</Notification>
</div>
);
} }
return ( return (
<> <>
@@ -115,6 +124,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
}; };
export default compose( export default compose(
translate("repos"),
withRouter, withRouter,
connect( connect(
mapStateToProps, mapStateToProps,

View File

@@ -16,7 +16,7 @@ import {
getBranches, getBranches,
getFetchBranchesFailure, getFetchBranchesFailure,
isFetchBranchesPending isFetchBranchesPending
} from "../modules/branches"; } from "../branches/modules/branches";
import { compose } from "redux"; import { compose } from "redux";
type Props = { type Props = {
@@ -40,7 +40,7 @@ type Props = {
t: string => string t: string => string
}; };
class BranchRoot extends React.Component<Props> { class ChangesetsRoot extends React.Component<Props> {
componentDidMount() { componentDidMount() {
this.props.fetchBranches(this.props.repository); this.props.fetchBranches(this.props.repository);
} }
@@ -146,4 +146,4 @@ export default compose(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
) )
)(BranchRoot); )(ChangesetsRoot);

View File

@@ -74,6 +74,7 @@ class DeleteRepo extends React.Component<Props> {
return ( return (
<> <>
<hr />
<Subtitle subtitle={t("deleteRepo.subtitle")} /> <Subtitle subtitle={t("deleteRepo.subtitle")} />
<ErrorNotification error={error} /> <ErrorNotification error={error} />
<div className="columns"> <div className="columns">

View File

@@ -70,7 +70,6 @@ class EditRepo extends React.Component<Props> {
this.props.modifyRepo(repo, this.repoModified); this.props.modifyRepo(repo, this.repoModified);
}} }}
/> />
<hr />
<ExtensionPoint <ExtensionPoint
name="repo-config.route" name="repo-config.route"
props={extensionProps} props={extensionProps}

View File

@@ -19,6 +19,7 @@ import {
PageActions, PageActions,
Button, Button,
CreateButton, CreateButton,
Notification,
LinkPaginator, LinkPaginator,
getPageFromMatch getPageFromMatch
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
@@ -97,15 +98,16 @@ class Overview extends React.Component<Props, State> {
history.push("/repos/?q=" + filter); history.push("/repos/?q=" + filter);
}} }}
> >
{this.renderList()} {this.renderOverview()}
{this.renderPageActionCreateButton()} {this.renderPageActionCreateButton()}
</Page> </Page>
); );
} }
renderList() { renderRepositoryList() {
const { collection, page } = this.props; const { collection, page, t } = this.props;
if (collection) {
if (collection._embedded && collection._embedded.repositories.length > 0) {
return ( return (
<> <>
<RepositoryList repositories={collection._embedded.repositories} /> <RepositoryList repositories={collection._embedded.repositories} />
@@ -114,6 +116,20 @@ class Overview extends React.Component<Props, State> {
page={page} page={page}
filter={this.getQueryString()} filter={this.getQueryString()}
/> />
</>
);
}
return (
<Notification type="info">{t("overview.noRepositories")}</Notification>
);
}
renderOverview() {
const { collection } = this.props;
if (collection) {
return (
<>
{this.renderRepositoryList()}
{this.renderCreateButton()} {this.renderCreateButton()}
</> </>
); );

View File

@@ -12,7 +12,6 @@ import { Redirect, Route, Switch } from "react-router-dom";
import type { Repository } from "@scm-manager/ui-types"; import type { Repository } from "@scm-manager/ui-types";
import { import {
CollapsibleErrorPage,
Loading, Loading,
Navigation, Navigation,
SubNavigation, SubNavigation,
@@ -24,12 +23,14 @@ import {
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import RepositoryDetails from "../components/RepositoryDetails"; import RepositoryDetails from "../components/RepositoryDetails";
import EditRepo from "./EditRepo"; import EditRepo from "./EditRepo";
import BranchesOverview from "../branches/containers/BranchesOverview";
import CreateBranch from "../branches/containers/CreateBranch";
import Permissions from "../permissions/containers/Permissions"; import Permissions from "../permissions/containers/Permissions";
import type { History } from "history"; import type { History } from "history";
import EditRepoNavLink from "../components/EditRepoNavLink"; import EditRepoNavLink from "../components/EditRepoNavLink";
import BranchRoot from "../branches/containers/BranchRoot";
import BranchRoot from "./ChangesetsRoot"; import ChangesetsRoot from "./ChangesetsRoot";
import ChangesetView from "./ChangesetView"; import ChangesetView from "./ChangesetView";
import PermissionsNavLink from "../components/PermissionsNavLink"; import PermissionsNavLink from "../components/PermissionsNavLink";
import Sources from "../sources/containers/Sources"; import Sources from "../sources/containers/Sources";
@@ -73,9 +74,15 @@ class RepositoryRoot extends React.Component<Props> {
return this.stripEndingSlash(this.props.match.url); return this.stripEndingSlash(this.props.match.url);
}; };
matches = (route: any) => { matchesBranches = (route: any) => {
const url = this.matchedUrl(); const url = this.matchedUrl();
const regex = new RegExp(`${url}(/branches)?/?[^/]*/changesets?.*`); const regex = new RegExp(`${url}/branch/.+/info`);
return route.location.pathname.match(regex);
};
matchesChangesets = (route: any) => {
const url = this.matchedUrl();
const regex = new RegExp(`${url}(/branch)?/?[^/]*/changesets?.*`);
return route.location.pathname.match(regex); return route.location.pathname.match(regex);
}; };
@@ -160,23 +167,46 @@ class RepositoryRoot extends React.Component<Props> {
<Route <Route
path={`${url}/changesets`} path={`${url}/changesets`}
render={() => ( render={() => (
<BranchRoot <ChangesetsRoot
repository={repository} repository={repository}
baseUrlWithBranch={`${url}/branches`} baseUrlWithBranch={`${url}/branch`}
baseUrlWithoutBranch={`${url}/changesets`} baseUrlWithoutBranch={`${url}/changesets`}
/> />
)} )}
/> />
<Route <Route
path={`${url}/branches/:branch/changesets`} path={`${url}/branch/:branch/changesets`}
render={() => ( render={() => (
<BranchRoot <ChangesetsRoot
repository={repository} repository={repository}
baseUrlWithBranch={`${url}/branches`} baseUrlWithBranch={`${url}/branch`}
baseUrlWithoutBranch={`${url}/changesets`} baseUrlWithoutBranch={`${url}/changesets`}
/> />
)} )}
/> />
<Route
path={`${url}/branch/:branch`}
render={() => (
<BranchRoot
repository={repository}
baseUrl={`${url}/branch`}
/>
)}
/>
<Route
path={`${url}/branches`}
exact={true}
render={() => (
<BranchesOverview
repository={repository}
baseUrl={`${url}/branch`}
/>
)}
/>
<Route
path={`${url}/branches/create`}
render={() => <CreateBranch repository={repository} />}
/>
<ExtensionPoint <ExtensionPoint
name="repository.route" name="repository.route"
props={extensionProps} props={extensionProps}
@@ -197,13 +227,22 @@ class RepositoryRoot extends React.Component<Props> {
icon="fas fa-info-circle" icon="fas fa-info-circle"
label={t("repositoryRoot.menu.informationNavLink")} label={t("repositoryRoot.menu.informationNavLink")}
/> />
<RepositoryNavLink
repository={repository}
linkName="branches"
to={`${url}/branches/`}
icon="fas fa-code-branch"
label={t("repositoryRoot.menu.branchesNavLink")}
activeWhenMatch={this.matchesBranches}
activeOnlyWhenExact={false}
/>
<RepositoryNavLink <RepositoryNavLink
repository={repository} repository={repository}
linkName="changesets" linkName="changesets"
to={`${url}/changesets/`} to={`${url}/changesets/`}
icon="fas fa-code-branch" icon="fas fa-exchange-alt"
label={t("repositoryRoot.menu.historyNavLink")} label={t("repositoryRoot.menu.historyNavLink")}
activeWhenMatch={this.matches} activeWhenMatch={this.matchesChangesets}
activeOnlyWhenExact={false} activeOnlyWhenExact={false}
/> />
<RepositoryNavLink <RepositoryNavLink

View File

@@ -1,134 +0,0 @@
// @flow
import {
FAILURE_SUFFIX,
PENDING_SUFFIX,
SUCCESS_SUFFIX
} from "../../modules/types";
import { apiClient } from "@scm-manager/ui-components";
import type { Action, Branch, Repository } from "@scm-manager/ui-types";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
export const FETCH_BRANCHES = "scm/repos/FETCH_BRANCHES";
export const FETCH_BRANCHES_PENDING = `${FETCH_BRANCHES}_${PENDING_SUFFIX}`;
export const FETCH_BRANCHES_SUCCESS = `${FETCH_BRANCHES}_${SUCCESS_SUFFIX}`;
export const FETCH_BRANCHES_FAILURE = `${FETCH_BRANCHES}_${FAILURE_SUFFIX}`;
// Fetching branches
export function fetchBranches(repository: Repository) {
if (!repository._links.branches) {
return {
type: FETCH_BRANCHES_SUCCESS,
payload: { repository, data: {} },
itemId: createKey(repository)
};
}
return function(dispatch: any) {
dispatch(fetchBranchesPending(repository));
return apiClient
.get(repository._links.branches.href)
.then(response => response.json())
.then(data => {
dispatch(fetchBranchesSuccess(data, repository));
})
.catch(error => {
dispatch(fetchBranchesFailure(repository, error));
});
};
}
// Action creators
export function fetchBranchesPending(repository: Repository) {
return {
type: FETCH_BRANCHES_PENDING,
payload: { repository },
itemId: createKey(repository)
};
}
export function fetchBranchesSuccess(data: string, repository: Repository) {
return {
type: FETCH_BRANCHES_SUCCESS,
payload: { data, repository },
itemId: createKey(repository)
};
}
export function fetchBranchesFailure(repository: Repository, error: Error) {
return {
type: FETCH_BRANCHES_FAILURE,
payload: { error, repository },
itemId: createKey(repository)
};
}
// Reducers
type State = { [string]: Branch[] };
export default function reducer(
state: State = {},
action: Action = { type: "UNKNOWN" }
): State {
if (!action.payload) {
return state;
}
const payload = action.payload;
switch (action.type) {
case FETCH_BRANCHES_SUCCESS:
const key = createKey(payload.repository);
return {
...state,
[key]: extractBranchesFromPayload(payload.data)
};
default:
return state;
}
}
function extractBranchesFromPayload(payload: any) {
if (payload._embedded && payload._embedded.branches) {
return payload._embedded.branches;
}
return [];
}
// Selectors
export function getBranches(state: Object, repository: Repository) {
const key = createKey(repository);
if (state.branches[key]) {
return state.branches[key];
}
return null;
}
export function getBranch(
state: Object,
repository: Repository,
name: string
): ?Branch {
const key = createKey(repository);
if (state.branches[key]) {
return state.branches[key].find((b: Branch) => b.name === name);
}
return null;
}
export function isFetchBranchesPending(
state: Object,
repository: Repository
): boolean {
return isPending(state, FETCH_BRANCHES, createKey(repository));
}
export function getFetchBranchesFailure(state: Object, repository: Repository) {
return getFailure(state, FETCH_BRANCHES, createKey(repository));
}
function createKey(repository: Repository): string {
const { namespace, name } = repository;
return `${namespace}/${name}`;
}

View File

@@ -1,195 +0,0 @@
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
FETCH_BRANCHES,
FETCH_BRANCHES_FAILURE,
FETCH_BRANCHES_PENDING,
FETCH_BRANCHES_SUCCESS,
fetchBranches,
getBranch,
getBranches,
getFetchBranchesFailure,
isFetchBranchesPending
} from "./branches";
const namespace = "foo";
const name = "bar";
const key = namespace + "/" + name;
const repository = {
namespace: "foo",
name: "bar",
_links: {
branches: {
href: "http://scm/api/rest/v2/repositories/foo/bar/branches"
}
}
};
const branch1 = { name: "branch1", revision: "revision1" };
const branch2 = { name: "branch2", revision: "revision2" };
const branch3 = { name: "branch3", revision: "revision3" };
describe("branches", () => {
describe("fetch branches", () => {
const URL = "http://scm/api/rest/v2/repositories/foo/bar/branches";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should fetch branches", () => {
const collection = {};
fetchMock.getOnce(URL, "{}");
const expectedActions = [
{
type: FETCH_BRANCHES_PENDING,
payload: { repository },
itemId: key
},
{
type: FETCH_BRANCHES_SUCCESS,
payload: { data: collection, repository },
itemId: key
}
];
const store = mockStore({});
return store.dispatch(fetchBranches(repository)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail fetching branches on HTTP 500", () => {
const collection = {};
fetchMock.getOnce(URL, 500);
const expectedActions = [
{
type: FETCH_BRANCHES_PENDING,
payload: { repository },
itemId: key
},
{
type: FETCH_BRANCHES_FAILURE,
payload: { error: collection, repository },
itemId: key
}
];
const store = mockStore({});
return store.dispatch(fetchBranches(repository)).then(() => {
expect(store.getActions()[0]).toEqual(expectedActions[0]);
expect(store.getActions()[1].type).toEqual(FETCH_BRANCHES_FAILURE);
});
});
});
describe("branches reducer", () => {
const branches = {
_embedded: {
branches: [branch1, branch2]
}
};
const action = {
type: FETCH_BRANCHES_SUCCESS,
payload: {
repository,
data: branches
}
};
it("should update state according to successful fetch", () => {
const newState = reducer({}, action);
expect(newState).toBeDefined();
expect(newState[key]).toBeDefined();
expect(newState[key]).toContain(branch1);
expect(newState[key]).toContain(branch2);
});
it("should not delete existing branches from state", () => {
const oldState = {
"hitchhiker/heartOfGold": [branch3]
};
const newState = reducer(oldState, action);
expect(newState[key]).toContain(branch1);
expect(newState[key]).toContain(branch2);
expect(newState["hitchhiker/heartOfGold"]).toContain(branch3);
});
});
describe("branch selectors", () => {
const error = new Error("Something went wrong");
const state = {
branches: {
[key]: [branch1, branch2]
}
};
it("should return true, when fetching branches is pending", () => {
const state = {
pending: {
[FETCH_BRANCHES + "/foo/bar"]: true
}
};
expect(isFetchBranchesPending(state, repository)).toBeTruthy();
});
it("should return branches", () => {
const branches = getBranches(state, repository);
expect(branches.length).toEqual(2);
expect(branches).toContain(branch1);
expect(branches).toContain(branch2);
});
it("should return always the same reference for branches", () => {
const one = getBranches(state, repository);
const two = getBranches(state, repository);
expect(one).toBe(two);
});
it("should return null, if no branches for the repository available", () => {
const branches = getBranches({ branches: {} }, repository);
expect(branches).toBeNull();
});
it("should return single branch by name", () => {
const branch = getBranch(state, repository, "branch1");
expect(branch).toEqual(branch1);
});
it("should return same reference for single branch by name", () => {
const one = getBranch(state, repository, "branch1");
const two = getBranch(state, repository, "branch1");
expect(one).toBe(two);
});
it("should return undefined if branch does not exist", () => {
const branch = getBranch(state, repository, "branch42");
expect(branch).toBeUndefined();
});
it("should return error if fetching branches failed", () => {
const state = {
failure: {
[FETCH_BRANCHES + "/foo/bar"]: error
}
};
expect(getFetchBranchesFailure(state, repository)).toEqual(error);
});
it("should return false if fetching branches did not fail", () => {
expect(getFetchBranchesFailure({}, repository)).toBeUndefined();
});
});
});

View File

@@ -5,7 +5,11 @@ import { connect } from "react-redux";
import injectSheet from "react-jss"; import injectSheet from "react-jss";
import FileTreeLeaf from "./FileTreeLeaf"; import FileTreeLeaf from "./FileTreeLeaf";
import type { Repository, File } from "@scm-manager/ui-types"; import type { Repository, File } from "@scm-manager/ui-types";
import { ErrorNotification, Loading } from "@scm-manager/ui-components"; import {
ErrorNotification,
Loading,
Notification
} from "@scm-manager/ui-components";
import { import {
getFetchSourcesFailure, getFetchSourcesFailure,
isFetchSourcesPending, isFetchSourcesPending,
@@ -48,16 +52,34 @@ export function findParent(path: string) {
class FileTree extends React.Component<Props> { class FileTree extends React.Component<Props> {
render() { render() {
const { const { error, loading, tree } = this.props;
error,
loading, if (error) {
tree, return <ErrorNotification error={error} />;
revision, }
path,
baseUrl, if (loading) {
classes, return <Loading />;
t }
} = this.props; if (!tree) {
return null;
}
return <div className="panel-block">{this.renderSourcesTable()}</div>;
}
renderSourcesTable() {
const { tree, revision, path, baseUrl, classes, t } = this.props;
const files = [];
if (path) {
files.push({
name: "..",
path: findParent(path),
directory: true
});
}
const compareFiles = function(f1: File, f2: File): number { const compareFiles = function(f1: File, f2: File): number {
if (f1.directory) { if (f1.directory) {
@@ -75,40 +97,19 @@ class FileTree extends React.Component<Props> {
} }
}; };
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
if (!tree) {
return null;
}
const files = [];
if (path) {
files.push({
name: "..",
path: findParent(path),
directory: true
});
}
if (tree._embedded && tree._embedded.children) { if (tree._embedded && tree._embedded.children) {
files.push(...tree._embedded.children.sort(compareFiles)); files.push(...tree._embedded.children.sort(compareFiles));
} }
let baseUrlWithRevision = baseUrl; if (files && files.length > 0) {
if (revision) { let baseUrlWithRevision = baseUrl;
baseUrlWithRevision += "/" + encodeURIComponent(revision); if (revision) {
} else { baseUrlWithRevision += "/" + encodeURIComponent(revision);
baseUrlWithRevision += "/" + encodeURIComponent(tree.revision); } else {
} baseUrlWithRevision += "/" + encodeURIComponent(tree.revision);
}
return ( return (
<div className="panel-block">
<table className="table table-hover table-sm is-fullwidth"> <table className="table table-hover table-sm is-fullwidth">
<thead> <thead>
<tr> <tr>
@@ -135,8 +136,9 @@ class FileTree extends React.Component<Props> {
))} ))}
</tbody> </tbody>
</table> </table>
</div> );
); }
return <Notification type="info">{t("sources.noSources")}</Notification>;
} }
} }

View File

@@ -12,7 +12,7 @@ import {
getBranches, getBranches,
getFetchBranchesFailure, getFetchBranchesFailure,
isFetchBranchesPending isFetchBranchesPending
} from "../../modules/branches"; } from "../../branches/modules/branches";
import { compose } from "redux"; import { compose } from "redux";
import Content from "./Content"; import Content from "./Content";
import { fetchSources, isDirectory } from "../modules/sources"; import { fetchSources, isDirectory } from "../modules/sources";
@@ -94,9 +94,7 @@ class Sources extends React.Component<Props> {
if (currentFileIsDirectory) { if (currentFileIsDirectory) {
return ( return (
<div className="panel"> <div className="panel">
<div className="panel-heading"> <div className="panel-heading">{this.renderBranchSelector()}</div>
{this.renderBranchSelector()}
</div>
<FileTree <FileTree
repository={repository} repository={repository}
revision={revision} revision={revision}

View File

@@ -20,7 +20,7 @@ export default class UserRow extends React.Component<Props> {
<td className="is-hidden-mobile">{this.renderLink(to, user.name)}</td> <td className="is-hidden-mobile">{this.renderLink(to, user.name)}</td>
<td>{this.renderLink(to, user.displayName)}</td> <td>{this.renderLink(to, user.displayName)}</td>
<td> <td>
<a href={`mailto: ${user.mail}`}>{user.mail}</a> <a href={`mailto:${user.mail}`}>{user.mail}</a>
</td> </td>
<td className="is-hidden-mobile"> <td className="is-hidden-mobile">
<input type="checkbox" id="active" checked={user.active} readOnly /> <input type="checkbox" id="active" checked={user.active} readOnly />

View File

@@ -9,8 +9,6 @@ type Props = {
users: User[] users: User[]
}; };
;
class UserTable extends React.Component<Props> { class UserTable extends React.Component<Props> {
render() { render() {
const { users, t } = this.props; const { users, t } = this.props;

View File

@@ -19,6 +19,7 @@ import {
PageActions, PageActions,
Button, Button,
CreateButton, CreateButton,
Notification,
LinkPaginator, LinkPaginator,
getPageFromMatch getPageFromMatch
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
@@ -88,14 +89,26 @@ class Users extends React.Component<Props, State> {
history.push("/users/?q=" + filter); history.push("/users/?q=" + filter);
}} }}
> >
<UserTable users={users} /> {this.renderUserTable()}
{this.renderPaginator()}
{this.renderCreateButton()} {this.renderCreateButton()}
{this.renderPageActionCreateButton()} {this.renderPageActionCreateButton()}
</Page> </Page>
); );
} }
renderUserTable() {
const { users, t } = this.props;
if (users && users.length > 0) {
return (
<>
<UserTable users={users} />
{this.renderPaginator()}
</>
);
}
return <Notification type="info">{t("users.noUsers")}</Notification>;
}
renderPaginator = () => { renderPaginator = () => {
const { list, page } = this.props; const { list, page } = this.props;
if (list) { if (list) {

View File

@@ -698,9 +698,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.26": "@scm-manager/ui-bundler@^0.0.27":
version "0.0.26" version "0.0.27"
resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.26.tgz#4676a7079b781b33fa1989c6643205c3559b1f66" resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.27.tgz#3ed2c7826780b9a1a9ea90464332640cfb5d54b5"
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"
@@ -5669,6 +5669,10 @@ memoize-one@^4.0.0:
version "4.0.3" version "4.0.3"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.3.tgz#cdfdd942853f1a1b4c71c5336b8c49da0bf0273c" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.0.3.tgz#cdfdd942853f1a1b4c71c5336b8c49da0bf0273c"
memoize-one@^5.0.4:
version "5.0.4"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.4.tgz#005928aced5c43d890a4dfab18ca908b0ec92cbc"
memoizee@0.4.X: memoizee@0.4.X:
version "0.4.14" version "0.4.14"
resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57"

View File

@@ -16,6 +16,8 @@ import javax.ws.rs.*;
import javax.ws.rs.core.Context; import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.net.URI;
import java.util.Optional;
@Path(AuthenticationResource.PATH) @Path(AuthenticationResource.PATH)
@AllowAnonymousAccess @AllowAnonymousAccess
@@ -28,6 +30,9 @@ public class AuthenticationResource {
private final AccessTokenBuilderFactory tokenBuilderFactory; private final AccessTokenBuilderFactory tokenBuilderFactory;
private final AccessTokenCookieIssuer cookieIssuer; private final AccessTokenCookieIssuer cookieIssuer;
@Inject(optional = true)
private LogoutRedirection logoutRedirection;
@Inject @Inject
public AuthenticationResource(AccessTokenBuilderFactory tokenBuilderFactory, AccessTokenCookieIssuer cookieIssuer) public AuthenticationResource(AccessTokenBuilderFactory tokenBuilderFactory, AccessTokenCookieIssuer cookieIssuer)
{ {
@@ -35,7 +40,6 @@ public class AuthenticationResource {
this.cookieIssuer = cookieIssuer; this.cookieIssuer = cookieIssuer;
} }
@POST @POST
@Path("access_token") @Path("access_token")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@@ -121,6 +125,7 @@ public class AuthenticationResource {
@DELETE @DELETE
@Path("access_token") @Path("access_token")
@Produces(MediaType.APPLICATION_JSON)
@StatusCodes({ @StatusCodes({
@ResponseCode(code = 204, condition = "success"), @ResponseCode(code = 204, condition = "success"),
@ResponseCode(code = 500, condition = "internal server error") @ResponseCode(code = 500, condition = "internal server error")
@@ -135,7 +140,19 @@ public class AuthenticationResource {
cookieIssuer.invalidate(request, response); cookieIssuer.invalidate(request, response);
// TODO anonymous access ?? // TODO anonymous access ??
return Response.noContent().build(); if (logoutRedirection == null) {
return Response.noContent().build();
} else {
Optional<URI> uri = logoutRedirection.afterLogoutRedirectTo();
if (uri.isPresent()) {
return Response.ok(new RedirectAfterLogoutDto(uri.get().toASCIIString())).build();
} else {
return Response.noContent().build();
}
}
} }
void setLogoutRedirection(LogoutRedirection logoutRedirection) {
this.logoutRedirection = logoutRedirection;
}
} }

View File

@@ -79,15 +79,16 @@ public class BranchRootResource {
@ResponseCode(code = 500, condition = "internal server error") @ResponseCode(code = 500, condition = "internal server error")
}) })
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("branch") String branchName) throws IOException { public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("branch") String branchName) throws IOException {
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name);
try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) {
Branches branches = repositoryService.getBranchesCommand().getBranches(); Branches branches = repositoryService.getBranchesCommand().getBranches();
return branches.getBranches() return branches.getBranches()
.stream() .stream()
.filter(branch -> branchName.equals(branch.getName())) .filter(branch -> branchName.equals(branch.getName()))
.findFirst() .findFirst()
.map(branch -> branchToDtoMapper.map(branch, new NamespaceAndName(namespace, name))) .map(branch -> branchToDtoMapper.map(branch, namespaceAndName))
.map(Response::ok) .map(Response::ok)
.orElse(Response.status(Response.Status.NOT_FOUND)) .orElseThrow(() -> notFound(entity("branch", branchName).in(namespaceAndName)))
.build(); .build();
} catch (CommandNotSupportedException ex) { } catch (CommandNotSupportedException ex) {
return Response.status(Response.Status.BAD_REQUEST).build(); return Response.status(Response.Status.BAD_REQUEST).build();

View File

@@ -0,0 +1,10 @@
package sonia.scm.api.v2.resources;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class RedirectAfterLogoutDto {
private String logoutRedirect;
}

View File

@@ -23,10 +23,18 @@ import sonia.scm.security.DefaultAccessTokenCookieIssuer;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.util.Date; import java.util.Date;
import java.util.Optional;
import static java.net.URI.create;
import static java.util.Optional.empty;
import static java.util.Optional.of;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -49,6 +57,8 @@ public class AuthenticationResourceTest {
private AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class)); private AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class));
private MockHttpResponse response = new MockHttpResponse();
private static final String AUTH_JSON_TRILLIAN = "{\n" + private static final String AUTH_JSON_TRILLIAN = "{\n" +
"\t\"cookie\": true,\n" + "\t\"cookie\": true,\n" +
"\t\"grant_type\": \"password\",\n" + "\t\"grant_type\": \"password\",\n" +
@@ -101,9 +111,11 @@ public class AuthenticationResourceTest {
"}" "}"
); );
private AuthenticationResource authenticationResource;
@Before @Before
public void prepareEnvironment() { public void prepareEnvironment() {
AuthenticationResource authenticationResource = new AuthenticationResource(accessTokenBuilderFactory, cookieIssuer); authenticationResource = new AuthenticationResource(accessTokenBuilderFactory, cookieIssuer);
dispatcher.getRegistry().addSingletonResource(authenticationResource); dispatcher.getRegistry().addSingletonResource(authenticationResource);
AccessToken accessToken = mock(AccessToken.class); AccessToken accessToken = mock(AccessToken.class);
@@ -123,7 +135,6 @@ public class AuthenticationResourceTest {
public void shouldAuthCorrectly() throws URISyntaxException { public void shouldAuthCorrectly() throws URISyntaxException {
MockHttpRequest request = getMockHttpRequest(AUTH_JSON_TRILLIAN); MockHttpRequest request = getMockHttpRequest(AUTH_JSON_TRILLIAN);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -134,7 +145,6 @@ public class AuthenticationResourceTest {
public void shouldAuthCorrectlyWithFormencodedData() throws URISyntaxException { public void shouldAuthCorrectlyWithFormencodedData() throws URISyntaxException {
MockHttpRequest request = getMockHttpRequestUrlEncoded(AUTH_FORMENCODED_TRILLIAN); MockHttpRequest request = getMockHttpRequestUrlEncoded(AUTH_FORMENCODED_TRILLIAN);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -146,7 +156,6 @@ public class AuthenticationResourceTest {
public void shouldNotAuthUserWithWrongPassword() throws URISyntaxException { public void shouldNotAuthUserWithWrongPassword() throws URISyntaxException {
MockHttpRequest request = getMockHttpRequest(AUTH_JSON_TRILLIAN_WRONG_PW); MockHttpRequest request = getMockHttpRequest(AUTH_JSON_TRILLIAN_WRONG_PW);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -156,7 +165,6 @@ public class AuthenticationResourceTest {
@Test @Test
public void shouldNotAuthNonexistingUser() throws URISyntaxException { public void shouldNotAuthNonexistingUser() throws URISyntaxException {
MockHttpRequest request = getMockHttpRequest(AUTH_JSON_NOT_EXISTING_USER); MockHttpRequest request = getMockHttpRequest(AUTH_JSON_NOT_EXISTING_USER);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -187,16 +195,36 @@ public class AuthenticationResourceTest {
@SubjectAware(username = "trillian", password = "secret") @SubjectAware(username = "trillian", password = "secret")
public void shouldSuccessfullyLogoutUser() throws URISyntaxException { public void shouldSuccessfullyLogoutUser() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.delete("/" + AuthenticationResource.PATH + "/access_token"); MockHttpRequest request = MockHttpRequest.delete("/" + AuthenticationResource.PATH + "/access_token");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus());
} }
@Test
public void shouldHandleLogoutRedirection() throws URISyntaxException, UnsupportedEncodingException {
authenticationResource.setLogoutRedirection(() -> of(create("http://example.com/cas/logout")));
MockHttpRequest request = MockHttpRequest.delete("/" + AuthenticationResource.PATH + "/access_token");
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
assertThat(response.getContentAsString(), containsString("http://example.com/cas/logout"));
}
@Test
public void shouldHandleDisabledLogoutRedirection() throws URISyntaxException {
authenticationResource.setLogoutRedirection(Optional::empty);
MockHttpRequest request = MockHttpRequest.delete("/" + AuthenticationResource.PATH + "/access_token");
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus());
}
private void shouldReturnBadRequest(String requestBody) throws URISyntaxException { private void shouldReturnBadRequest(String requestBody) throws URISyntaxException {
MockHttpRequest request = getMockHttpRequest(requestBody); MockHttpRequest request = getMockHttpRequest(requestBody);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
@@ -218,5 +246,4 @@ public class AuthenticationResourceTest {
request.contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); request.contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE);
return request; return request;
} }
} }

View File

@@ -129,6 +129,7 @@ public class BranchRootResourceTest extends RepositoryTestBase {
dispatcher.invoke(request, response); dispatcher.invoke(request, response);
assertEquals(404, response.getStatus()); assertEquals(404, response.getStatus());
assertEquals("application/vnd.scmm-error+json;v=2", response.getOutputHeaders().getFirst("Content-Type"));
} }
@Test @Test