diff --git a/scm-plugins/scm-git-plugin/src/main/js/CloneInformation.js b/scm-plugins/scm-git-plugin/src/main/js/CloneInformation.js index 177e662335..cdd08927e4 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/CloneInformation.js +++ b/scm-plugins/scm-git-plugin/src/main/js/CloneInformation.js @@ -8,11 +8,10 @@ type Props = { repository: Repository, // context props - t: (string) => string + t: string => string }; class CloneInformation extends React.Component { - render() { const { url, repository, t } = this.props; @@ -51,7 +50,6 @@ class CloneInformation extends React.Component { ); } - } export default translate("plugins")(CloneInformation); diff --git a/scm-plugins/scm-git-plugin/src/main/js/GitBranchInformation.js b/scm-plugins/scm-git-plugin/src/main/js/GitBranchInformation.js new file mode 100644 index 0000000000..b4c1873e9f --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/js/GitBranchInformation.js @@ -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 { + render() { + const { branch, t } = this.props; + + return ( +
+

{t("scm-git-plugin.information.fetch")}

+
+          git fetch
+        
+

{t("scm-git-plugin.information.checkout")}

+
+          git checkout {branch.name}
+        
+
+ ); + } +} + +export default translate("plugins")(GitBranchInformation); diff --git a/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js b/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js index 692611510f..bc7e51102a 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js +++ b/scm-plugins/scm-git-plugin/src/main/js/GitConfigurationForm.js @@ -10,7 +10,7 @@ type Configuration = { gcExpression?: string, nonFastForwardDisallowed: boolean, _links: Links -} +}; type Props = { initialConfiguration: Configuration, @@ -19,25 +19,24 @@ type Props = { onConfigurationChange: (Configuration, boolean) => void, // context props - t: (string) => string -} + t: string => string +}; -type State = Configuration & { - -} +type State = Configuration & {}; class GitConfigurationForm extends React.Component { - constructor(props: Props) { super(props); this.state = { ...props.initialConfiguration }; } - handleChange = (value: any, name: string) => { - this.setState({ - [name]: value - }, () => this.props.onConfigurationChange(this.state, true)); + this.setState( + { + [name]: value + }, + () => this.props.onConfigurationChange(this.state, true) + ); }; render() { @@ -46,24 +45,25 @@ class GitConfigurationForm extends React.Component { return ( <> - - ); } - } export default translate("plugins")(GitConfigurationForm); diff --git a/scm-plugins/scm-git-plugin/src/main/js/index.js b/scm-plugins/scm-git-plugin/src/main/js/index.js index 43e3950beb..0dafbe4227 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/index.js +++ b/scm-plugins/scm-git-plugin/src/main/js/index.js @@ -6,6 +6,7 @@ import GitAvatar from "./GitAvatar"; import {ConfigurationBinder as cfgBinder} from "@scm-manager/ui-components"; import GitGlobalConfiguration from "./GitGlobalConfiguration"; +import GitBranchInformation from "./GitBranchInformation"; import GitMergeInformation from "./GitMergeInformation"; import RepositoryConfig from "./RepositoryConfig"; @@ -20,6 +21,11 @@ binder.bind( ProtocolInformation, gitPredicate ); +binder.bind( + "repos.branch-details.information", + GitBranchInformation, + gitPredicate +); binder.bind( "repos.repository-merge.information", GitMergeInformation, diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json index 578d859c8e..e150ceb42b 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json @@ -1,9 +1,11 @@ { "scm-git-plugin": { "information": { - "clone" : "Repository klonen", - "create" : "Neues Repository erstellen", - "replace" : "Ein bestehendes Repository aktualisieren", + "clone": "Repository klonen", + "create": "Neues Repository erstellen", + "replace": "Ein bestehendes Repository aktualisieren", + "fetch": "Remote-Änderungen herunterladen", + "checkout": "Branch wechseln", "merge": { "heading": "Merge des Source Branch in den Target Branch", "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!" } }, - "permissions" : { + "permissions": { "configuration": { "read,write": { "git": { diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json index bea0a08dc9..22eb46b9f8 100644 --- a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json @@ -4,6 +4,8 @@ "clone": "Clone the repository", "create": "Create a new repository", "replace": "Push an existing repository", + "fetch": "Get remote changes", + "checkout": "Switch branch", "merge": { "heading": "How to merge source branch into target branch", "checkout": "1. Make sure your workspace is clean and checkout target branch", diff --git a/scm-plugins/scm-hg-plugin/src/main/js/HgBranchInformation.js b/scm-plugins/scm-hg-plugin/src/main/js/HgBranchInformation.js new file mode 100644 index 0000000000..358a682054 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/js/HgBranchInformation.js @@ -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 { + render() { + const { branch, t } = this.props; + + return ( +
+

{t("scm-hg-plugin.information.fetch")}

+
+          hg pull
+        
+

{t("scm-hg-plugin.information.checkout")}

+
+          hg update {branch.name}
+        
+
+ ); + } +} + +export default translate("plugins")(HgBranchInformation); diff --git a/scm-plugins/scm-hg-plugin/src/main/js/index.js b/scm-plugins/scm-hg-plugin/src/main/js/index.js index a1fa72f5bd..9df4512d10 100644 --- a/scm-plugins/scm-hg-plugin/src/main/js/index.js +++ b/scm-plugins/scm-hg-plugin/src/main/js/index.js @@ -4,14 +4,29 @@ import ProtocolInformation from "./ProtocolInformation"; import HgAvatar from "./HgAvatar"; import { ConfigurationBinder as cfgBinder } from "@scm-manager/ui-components"; import HgGlobalConfiguration from "./HgGlobalConfiguration"; +import HgBranchInformation from "./HgBranchInformation"; const hgPredicate = (props: Object) => { 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); // bind global configuration -cfgBinder.bindGlobal("/hg", "scm-hg-plugin.config.link", "hgConfig", HgGlobalConfiguration); +cfgBinder.bindGlobal( + "/hg", + "scm-hg-plugin.config.link", + "hgConfig", + HgGlobalConfiguration +); diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json index 63a8cc8a98..d32847a3af 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json +++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json @@ -3,7 +3,9 @@ "information": { "clone" : "Repository klonen", "create" : "Neues Repository erstellen", - "replace" : "Ein bestehendes Repository aktualisieren" + "replace" : "Ein bestehendes Repository aktualisieren", + "fetch": "Remote-Änderungen herunterladen", + "checkout": "Branch wechseln" }, "config": { "link": "Mercurial", diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json index a5d05d5796..3792bd4a47 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json +++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json @@ -3,7 +3,9 @@ "information": { "clone" : "Clone the repository", "create" : "Create a new repository", - "replace" : "Push an existing repository" + "replace" : "Push an existing repository", + "fetch": "Get remote changes", + "checkout": "Switch branch" }, "config": { "link": "Mercurial", diff --git a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js index de05efb46e..04df747c1a 100644 --- a/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js +++ b/scm-ui-components/packages/ui-components/src/repos/changesets/ChangesetButtonGroup.js @@ -11,11 +11,10 @@ type Props = { changeset: Changeset, // context props - t: (string) => string -} + t: string => string +}; class ChangesetButtonGroup extends React.Component { - render() { const { repository, changeset, t } = this.props; @@ -26,7 +25,7 @@ class ChangesetButtonGroup extends React.Component { + + + ); + } +} + +export default translate("repos")(BranchButtonGroup); diff --git a/scm-ui/src/repos/branches/components/BranchDetail.js b/scm-ui/src/repos/branches/components/BranchDetail.js new file mode 100644 index 0000000000..86b142d9da --- /dev/null +++ b/scm-ui/src/repos/branches/components/BranchDetail.js @@ -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 { + render() { + const { repository, branch, t } = this.props; + + return ( +
+
+ {t("branch.name")} {branch.name}{" "} + +
+
+ +
+
+ ); + } +} + +export default translate("repos")(BranchDetail); diff --git a/scm-ui/src/repos/branches/components/BranchForm.js b/scm-ui/src/repos/branches/components/BranchForm.js new file mode 100644 index 0000000000..614a719338 --- /dev/null +++ b/scm-ui/src/repos/branches/components/BranchForm.js @@ -0,0 +1,16 @@ +//@flow +import React from "react"; + +type Props = {}; + +class CreateBranch extends React.Component { + render() { + return ( + <> +

Form placeholder

+ + ); + } +} + +export default translate("repos")(BranchForm); diff --git a/scm-ui/src/repos/branches/components/BranchRow.js b/scm-ui/src/repos/branches/components/BranchRow.js new file mode 100644 index 0000000000..025e8f2742 --- /dev/null +++ b/scm-ui/src/repos/branches/components/BranchRow.js @@ -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 { + renderLink(to: string, label: string, defaultBranch?: boolean) { + return ( + + {label} + + ); + } + + render() { + const { baseUrl, branch } = this.props; + const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`; + return ( + + {this.renderLink(to, branch.name, branch.defaultBranch)} + + ); + } +} + +export default BranchRow; diff --git a/scm-ui/src/repos/branches/components/BranchTable.js b/scm-ui/src/repos/branches/components/BranchTable.js new file mode 100644 index 0000000000..85dd854f21 --- /dev/null +++ b/scm-ui/src/repos/branches/components/BranchTable.js @@ -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 { + render() { + const { t } = this.props; + return ( + + + + + + + {this.renderRow()} +
{t("branches.table.branches")}
+ ); + } + + renderRow() { + const { baseUrl, branches } = this.props; + let rowContent = null; + if (branches) { + rowContent = branches.map((branch, index) => { + return ; + }); + } + return rowContent; + } +} + +export default translate("repos")(BranchTable); diff --git a/scm-ui/src/repos/branches/components/BranchView.js b/scm-ui/src/repos/branches/components/BranchView.js new file mode 100644 index 0000000000..a7dd7dbd4b --- /dev/null +++ b/scm-ui/src/repos/branches/components/BranchView.js @@ -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 { + render() { + const { repository, branch } = this.props; + + return ( +
+ +
+
+ +
+
+ ); + } +} + +export default BranchView; diff --git a/scm-ui/src/repos/branches/components/DefaultBranchTag.js b/scm-ui/src/repos/branches/components/DefaultBranchTag.js new file mode 100644 index 0000000000..18be6d21f8 --- /dev/null +++ b/scm-ui/src/repos/branches/components/DefaultBranchTag.js @@ -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 { + render() { + const { defaultBranch, classes, t } = this.props; + + if (defaultBranch) { + return ( + + {t("branch.defaultTag")} + + ); + } + return null; + } +} + +export default injectSheet(styles)(translate("repos")(DefaultBranchTag)); diff --git a/scm-ui/src/repos/branches/containers/BranchRoot.js b/scm-ui/src/repos/branches/containers/BranchRoot.js new file mode 100644 index 0000000000..2e9fb10f73 --- /dev/null +++ b/scm-ui/src/repos/branches/containers/BranchRoot.js @@ -0,0 +1,119 @@ +//@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"; + +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 { + 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 && location.search.indexOf("?create=true") > -1) { + return ; + } + + return ( + + ); + } + + if (loading || !branch) { + return ; + } + + return ( + + + ( + + )} + /> + + ); + } +} + +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) +); diff --git a/scm-ui/src/repos/branches/containers/BranchesOverview.js b/scm-ui/src/repos/branches/containers/BranchesOverview.js new file mode 100644 index 0000000000..f8c8be7529 --- /dev/null +++ b/scm-ui/src/repos/branches/containers/BranchesOverview.js @@ -0,0 +1,112 @@ +// @flow +import React from "react"; +import { + fetchBranches, + getBranches, + getFetchBranchesFailure, + isFetchBranchesPending +} 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, + 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 { + componentDidMount() { + const { fetchBranches, repository } = this.props; + fetchBranches(repository); + } + + render() { + const { baseUrl, loading, error, branches, t } = this.props; + + if (error) { + return ; + } + + if (!branches || loading) { + return ; + } + + orderBranches(branches); + + return ( + <> + + + {this.renderCreateButton()} + + ); + } + + renderCreateButton() { + const { showCreateButton, t } = this.props; + if (showCreateButton || true) { + // TODO + return ( + + ); + } + return null; + } +} + +const mapStateToProps = (state, ownProps) => { + const { repository } = ownProps; + const loading = isFetchBranchesPending(state, repository); + const error = getFetchBranchesFailure(state, repository); + const branches = getBranches(state, repository); + + return { + repository, + loading, + error, + branches + }; +}; + +const mapDispatchToProps = dispatch => { + return { + fetchBranches: (repository: Repository) => { + dispatch(fetchBranches(repository)); + } + }; +}; + +export default compose( + translate("repos"), + withRouter, + connect( + mapStateToProps, + mapDispatchToProps + ) +)(BranchesOverview); diff --git a/scm-ui/src/repos/branches/containers/CreateBranch.js b/scm-ui/src/repos/branches/containers/CreateBranch.js new file mode 100644 index 0000000000..63b72da58f --- /dev/null +++ b/scm-ui/src/repos/branches/containers/CreateBranch.js @@ -0,0 +1,23 @@ +//@flow +import React from "react"; +import { Subtitle } from "@scm-manager/ui-components"; +import {translate} from "react-i18next"; + +type Props = { + t: string => string +}; + +class CreateBranch extends React.Component { + render() { + const { t } = this.props; + + return ( + <> + +

Create placeholder

+ + ); + } +} + +export default translate("repos")(CreateBranch); diff --git a/scm-ui/src/repos/modules/branches.js b/scm-ui/src/repos/branches/modules/branches.js similarity index 53% rename from scm-ui/src/repos/modules/branches.js rename to scm-ui/src/repos/branches/modules/branches.js index f819b07700..44543106ca 100644 --- a/scm-ui/src/repos/modules/branches.js +++ b/scm-ui/src/repos/branches/modules/branches.js @@ -3,17 +3,22 @@ import { FAILURE_SUFFIX, PENDING_SUFFIX, SUCCESS_SUFFIX -} from "../../modules/types"; +} 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"; +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}`; +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}`; + // Fetching branches export function fetchBranches(repository: Repository) { @@ -39,60 +44,29 @@ export function fetchBranches(repository: Repository) { }; } -// 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; +export function fetchBranch( + repository: Repository, + name: string +) { + let link = repository._links.branches.href; + if (!link.endsWith("/")) { + link += "/"; } - 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 []; + 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)); + }); + }; } // Selectors @@ -117,6 +91,7 @@ export function getBranch( return null; } +// Action creators export function isFetchBranchesPending( state: Object, repository: Repository @@ -128,6 +103,131 @@ export function getFetchBranchesFailure(state: Object, repository: Repository) { return getFailure(state, FETCH_BRANCHES, 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 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 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 + +function extractBranchesFromPayload(payload: any) { + if (payload._embedded && payload._embedded.branches) { + return payload._embedded.branches; + } + return []; +} + +function reduceBranchSuccess(state, repositoryName, newBranch) { + const newBranches = []; + // we do not use filter, because we try to keep the current order + let found = false; + for (const branch of state[repositoryName] || []) { + if (branch.name === newBranch.name) { + newBranches.push(newBranch); + found = true; + } else { + newBranches.push(branch); + } + } + if (!found) { + newBranches.push(newBranch); + } + return newBranches; +} + +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) + }; + case FETCH_BRANCH_SUCCESS: + if (!action.payload.repository || !action.payload.branch) { + return state; + } + const newBranch = action.payload.branch; + const repositoryName = createKey(action.payload.repository); + return { + ...state, + [repositoryName]: reduceBranchSuccess(state, repositoryName, newBranch) + }; + default: + return state; + } +} + function createKey(repository: Repository): string { const { namespace, name } = repository; return `${namespace}/${name}`; diff --git a/scm-ui/src/repos/modules/branches.test.js b/scm-ui/src/repos/branches/modules/branches.test.js similarity index 65% rename from scm-ui/src/repos/modules/branches.test.js rename to scm-ui/src/repos/branches/modules/branches.test.js index bcbae28611..99c9bba60a 100644 --- a/scm-ui/src/repos/modules/branches.test.js +++ b/scm-ui/src/repos/branches/modules/branches.test.js @@ -6,7 +6,12 @@ import reducer, { FETCH_BRANCHES_FAILURE, FETCH_BRANCHES_PENDING, FETCH_BRANCHES_SUCCESS, + FETCH_BRANCH_PENDING, + FETCH_BRANCH_SUCCESS, + FETCH_BRANCH_FAILURE, fetchBranches, + fetchBranch, + fetchBranchSuccess, getBranch, getBranches, getFetchBranchesFailure, @@ -88,6 +93,32 @@ describe("branches", () => { 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(); + }); + }); }); describe("branches reducer", () => { @@ -116,13 +147,70 @@ describe("branches", () => { 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); }); + + it("should update state according to FETCH_BRANCH_SUCCESS action", () => { + const newState = reducer({}, fetchBranchSuccess(repository, branch3)); + expect(newState["foo/bar"]).toEqual([branch3]); + }); + + it("should not delete existing branch from state", () => { + const oldState = { + "foo/bar": [branch1] + }; + const newState = reducer( + oldState, + fetchBranchSuccess(repository, branch2) + ); + expect(newState["foo/bar"]).toEqual([branch1, branch2]); + }); + + it("should update required branch from state", () => { + const oldState = { + "foo/bar": [branch1] + }; + const newBranch1 = { name: "branch1", revision: "revision2" }; + const newState = reducer( + oldState, + fetchBranchSuccess(repository, newBranch1) + ); + expect(newState["foo/bar"]).toEqual([newBranch1]); + }); + + it("should update required branch from state and keeps old repo", () => { + const oldState = { + "ns/one": [branch1] + }; + const newState = reducer( + oldState, + fetchBranchSuccess(repository, branch3) + ); + expect(newState["ns/one"]).toEqual([branch1]); + expect(newState["foo/bar"]).toEqual([branch3]); + }); + + it("should return the oldState, if action has no payload", () => { + const state = {}; + const newState = reducer(state, { type: FETCH_BRANCH_SUCCESS }); + expect(newState).toBe(state); + }); + + it("should return the oldState, if payload has no branch", () => { + const action = { + type: FETCH_BRANCH_SUCCESS, + payload: { + repository + }, + itemId: "foo/bar/" + }; + const state = {}; + const newState = reducer(state, action); + expect(newState).toBe(state); + }); }); describe("branch selectors", () => { diff --git a/scm-ui/src/repos/branches/util/orderBranches.js b/scm-ui/src/repos/branches/util/orderBranches.js new file mode 100644 index 0000000000..a1c2f5e460 --- /dev/null +++ b/scm-ui/src/repos/branches/util/orderBranches.js @@ -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; + }); +} diff --git a/scm-ui/src/repos/branches/util/orderBranches.test.js b/scm-ui/src/repos/branches/util/orderBranches.test.js new file mode 100644 index 0000000000..557cc8a4c5 --- /dev/null +++ b/scm-ui/src/repos/branches/util/orderBranches.test.js @@ -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 + ]); + }); +}); diff --git a/scm-ui/src/repos/components/list/RepositoryEntry.js b/scm-ui/src/repos/components/list/RepositoryEntry.js index b8b03a3523..0a7cf0d434 100644 --- a/scm-ui/src/repos/components/list/RepositoryEntry.js +++ b/scm-ui/src/repos/components/list/RepositoryEntry.js @@ -35,11 +35,23 @@ class RepositoryEntry extends React.Component { return `/repo/${repository.namespace}/${repository.name}`; }; + renderBranchesLink = (repository: Repository, repositoryLink: string) => { + if (repository._links["branches"]) { + return ( + + ); + } + return null; + }; + renderChangesetsLink = (repository: Repository, repositoryLink: string) => { if (repository._links["changesets"]) { return ( ); @@ -102,6 +114,7 @@ class RepositoryEntry extends React.Component {