diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js
index 536227aec6..331d5e7c7b 100644
--- a/scm-ui/src/containers/Main.js
+++ b/scm-ui/src/containers/Main.js
@@ -1,9 +1,9 @@
//@flow
import React from "react";
-import { Route, withRouter } from "react-router";
+import { Route, Redirect, withRouter } from "react-router";
-import Repositories from "../repositories/containers/Repositories";
+import Overview from "../repos/containers/Overview";
import Users from "../users/containers/Users";
import Login from "../containers/Login";
import Logout from "../containers/Logout";
@@ -12,6 +12,8 @@ import { Switch } from "react-router-dom";
import ProtectedRoute from "../components/ProtectedRoute";
import AddUser from "../users/containers/AddUser";
import SingleUser from "../users/containers/SingleUser";
+import RepositoryRoot from "../repos/containers/RepositoryRoot";
+import Create from "../repos/containers/Create";
type Props = {
authenticated?: boolean
@@ -21,16 +23,34 @@ class Main extends React.Component
{
render() {
const { authenticated } = this.props;
return (
-
+
-
+
+
+
+
+
void,
+
+ // context props
+ t: string => string
+};
+
+class DeleteNavAction extends React.Component {
+ static defaultProps = {
+ confirmDialog: true
+ };
+
+ delete = () => {
+ this.props.delete(this.props.repository);
+ };
+
+ confirmDelete = () => {
+ const { t } = this.props;
+ confirmAlert({
+ title: t("delete-nav-action.confirm-alert.title"),
+ message: t("delete-nav-action.confirm-alert.message"),
+ buttons: [
+ {
+ label: t("delete-nav-action.confirm-alert.submit"),
+ onClick: () => this.delete()
+ },
+ {
+ label: t("delete-nav-action.confirm-alert.cancel"),
+ onClick: () => null
+ }
+ ]
+ });
+ };
+
+ isDeletable = () => {
+ return this.props.repository._links.delete;
+ };
+
+ render() {
+ const { confirmDialog, t } = this.props;
+ const action = confirmDialog ? this.confirmDelete : this.delete();
+
+ if (!this.isDeletable()) {
+ return null;
+ }
+ return ;
+ }
+}
+
+export default translate("repos")(DeleteNavAction);
diff --git a/scm-ui/src/repos/components/DeleteNavAction.test.js b/scm-ui/src/repos/components/DeleteNavAction.test.js
new file mode 100644
index 0000000000..635f84c7a9
--- /dev/null
+++ b/scm-ui/src/repos/components/DeleteNavAction.test.js
@@ -0,0 +1,79 @@
+import React from "react";
+import { mount, shallow } from "enzyme";
+import "../../tests/enzyme";
+import "../../tests/i18n";
+import DeleteNavAction from "./DeleteNavAction";
+
+import { confirmAlert } from "../../components/modals/ConfirmAlert";
+jest.mock("../../components/modals/ConfirmAlert");
+
+describe("DeleteNavAction", () => {
+ it("should render nothing, if the delete link is missing", () => {
+ const repository = {
+ _links: {}
+ };
+
+ const navLink = shallow(
+ {}} />
+ );
+ expect(navLink.text()).toBe("");
+ });
+
+ it("should render the navLink", () => {
+ const repository = {
+ _links: {
+ delete: {
+ href: "/repositories"
+ }
+ }
+ };
+
+ const navLink = mount(
+ {}} />
+ );
+ expect(navLink.text()).not.toBe("");
+ });
+
+ it("should open the confirm dialog on navLink click", () => {
+ const repository = {
+ _links: {
+ delete: {
+ href: "/repositorys"
+ }
+ }
+ };
+
+ const navLink = mount(
+ {}} />
+ );
+ navLink.find("a").simulate("click");
+
+ expect(confirmAlert.mock.calls.length).toBe(1);
+ });
+
+ it("should call the delete repository function with delete url", () => {
+ const repository = {
+ _links: {
+ delete: {
+ href: "/repos"
+ }
+ }
+ };
+
+ let calledUrl = null;
+ function capture(repository) {
+ calledUrl = repository._links.delete.href;
+ }
+
+ const navLink = mount(
+
+ );
+ navLink.find("a").simulate("click");
+
+ expect(calledUrl).toBe("/repos");
+ });
+});
diff --git a/scm-ui/src/repos/components/EditNavLink.js b/scm-ui/src/repos/components/EditNavLink.js
new file mode 100644
index 0000000000..2a5c4adf9d
--- /dev/null
+++ b/scm-ui/src/repos/components/EditNavLink.js
@@ -0,0 +1,22 @@
+//@flow
+import React from "react";
+import { NavLink } from "../../components/navigation";
+import { translate } from "react-i18next";
+import type { Repository } from "../types/Repositories";
+
+type Props = { editUrl: string, t: string => string, repository: Repository };
+
+class EditNavLink extends React.Component {
+ isEditable = () => {
+ return this.props.repository._links.update;
+ };
+ render() {
+ if (!this.isEditable()) {
+ return null;
+ }
+ const { editUrl, t } = this.props;
+ return ;
+ }
+}
+
+export default translate("repos")(EditNavLink);
diff --git a/scm-ui/src/repos/components/EditNavLink.test.js b/scm-ui/src/repos/components/EditNavLink.test.js
new file mode 100644
index 0000000000..8289f0fb3b
--- /dev/null
+++ b/scm-ui/src/repos/components/EditNavLink.test.js
@@ -0,0 +1,32 @@
+import React from "react";
+import { mount, shallow } from "enzyme";
+import "../../tests/enzyme";
+import "../../tests/i18n";
+import EditNavLink from "./EditNavLink";
+
+jest.mock("../../components/modals/ConfirmAlert");
+jest.mock("../../components/navigation/NavLink", () => () => foo
);
+
+describe("EditNavLink", () => {
+ it("should render nothing, if the modify link is missing", () => {
+ const repository = {
+ _links: {}
+ };
+
+ const navLink = shallow();
+ expect(navLink.text()).toBe("");
+ });
+
+ it("should render the navLink", () => {
+ const repository = {
+ _links: {
+ update: {
+ href: "/repositories"
+ }
+ }
+ };
+
+ const navLink = mount();
+ expect(navLink.text()).toBe("foo");
+ });
+});
diff --git a/scm-ui/src/repos/components/RepositoryDetails.js b/scm-ui/src/repos/components/RepositoryDetails.js
new file mode 100644
index 0000000000..83a4f164aa
--- /dev/null
+++ b/scm-ui/src/repos/components/RepositoryDetails.js
@@ -0,0 +1,56 @@
+//@flow
+import React from "react";
+import { translate } from "react-i18next";
+import type { Repository } from "../types/Repositories";
+import MailLink from "../../components/MailLink";
+import DateFromNow from "../../components/DateFromNow";
+
+type Props = {
+ repository: Repository,
+ // context props
+ t: string => string
+};
+
+class RepositoryDetails extends React.Component {
+ render() {
+ const { repository, t } = this.props;
+ return (
+
+
+
+ | {t("repository.name")} |
+ {repository.name} |
+
+
+ | {t("repository.type")} |
+ {repository.type} |
+
+
+ | {t("repository.contact")} |
+
+
+ |
+
+
+ | {t("repository.description")} |
+ {repository.description} |
+
+
+ | {t("repository.creationDate")} |
+
+
+ |
+
+
+ | {t("repository.lastModified")} |
+
+
+ |
+
+
+
+ );
+ }
+}
+
+export default translate("repos")(RepositoryDetails);
diff --git a/scm-ui/src/repos/components/form/RepositoryForm.js b/scm-ui/src/repos/components/form/RepositoryForm.js
new file mode 100644
index 0000000000..bde5d20d3b
--- /dev/null
+++ b/scm-ui/src/repos/components/form/RepositoryForm.js
@@ -0,0 +1,168 @@
+// @flow
+import React from "react";
+import { translate } from "react-i18next";
+import { InputField, Select } from "../../../components/forms/index";
+import { SubmitButton } from "../../../components/buttons/index";
+import type { Repository } from "../../types/Repositories";
+import * as validator from "./repositoryValidation";
+import type { RepositoryType } from "../../types/RepositoryTypes";
+import Textarea from "../../../components/forms/Textarea";
+
+type Props = {
+ submitForm: Repository => void,
+ repository?: Repository,
+ repositoryTypes: RepositoryType[],
+ loading?: boolean,
+ t: string => string
+};
+
+type State = {
+ repository: Repository,
+ nameValidationError: boolean,
+ contactValidationError: boolean
+};
+
+class RepositoryForm extends React.Component {
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ repository: {
+ name: "",
+ namespace: "",
+ type: "",
+ contact: "",
+ description: "",
+ _links: {}
+ },
+ nameValidationError: false,
+ contactValidationError: false,
+ descriptionValidationError: false
+ };
+ }
+
+ componentDidMount() {
+ const { repository } = this.props;
+ if (repository) {
+ this.setState({ repository: { ...repository } });
+ }
+ }
+
+ isFalsy(value) {
+ if (!value) {
+ return true;
+ }
+ return false;
+ }
+
+ isValid = () => {
+ const repository = this.state.repository;
+ return !(
+ this.state.nameValidationError ||
+ this.state.contactValidationError ||
+ this.isFalsy(repository.name)
+ );
+ };
+
+ submit = (event: Event) => {
+ event.preventDefault();
+ if (this.isValid()) {
+ this.props.submitForm(this.state.repository);
+ }
+ };
+
+ isCreateMode = () => {
+ return !this.props.repository;
+ };
+
+ render() {
+ const { loading, t } = this.props;
+ const repository = this.state.repository;
+
+ return (
+
+ );
+ }
+
+ createSelectOptions(repositoryTypes: RepositoryType[]) {
+ return repositoryTypes.map(repositoryType => {
+ return {
+ label: repositoryType.displayName,
+ value: repositoryType.name
+ };
+ });
+ }
+
+ renderCreateOnlyFields() {
+ if (!this.isCreateMode()) {
+ return null;
+ }
+ const { repositoryTypes, t } = this.props;
+ const repository = this.state.repository;
+ return (
+
+
+
+
+ );
+ }
+
+ handleNameChange = (name: string) => {
+ this.setState({
+ nameValidationError: !validator.isNameValid(name),
+ repository: { ...this.state.repository, name }
+ });
+ };
+
+ handleTypeChange = (type: string) => {
+ this.setState({
+ repository: { ...this.state.repository, type }
+ });
+ };
+
+ handleContactChange = (contact: string) => {
+ this.setState({
+ contactValidationError: !validator.isContactValid(contact),
+ repository: { ...this.state.repository, contact }
+ });
+ };
+
+ handleDescriptionChange = (description: string) => {
+ this.setState({
+ repository: { ...this.state.repository, description }
+ });
+ };
+}
+
+export default translate("repos")(RepositoryForm);
diff --git a/scm-ui/src/repos/components/form/index.js b/scm-ui/src/repos/components/form/index.js
new file mode 100644
index 0000000000..4af39bf51e
--- /dev/null
+++ b/scm-ui/src/repos/components/form/index.js
@@ -0,0 +1,2 @@
+import RepositoryForm from "./RepositoryForm";
+export default RepositoryForm;
diff --git a/scm-ui/src/repos/components/form/repositoryValidation.js b/scm-ui/src/repos/components/form/repositoryValidation.js
new file mode 100644
index 0000000000..dbd099144f
--- /dev/null
+++ b/scm-ui/src/repos/components/form/repositoryValidation.js
@@ -0,0 +1,10 @@
+// @flow
+import * as generalValidator from "../../../components/validation";
+
+export const isNameValid = (name: string) => {
+ return generalValidator.isNameValid(name);
+};
+
+export function isContactValid(mail: string) {
+ return "" === mail || generalValidator.isMailValid(mail);
+}
diff --git a/scm-ui/src/repos/components/form/repositoryValidation.test.js b/scm-ui/src/repos/components/form/repositoryValidation.test.js
new file mode 100644
index 0000000000..bcb29f3ef7
--- /dev/null
+++ b/scm-ui/src/repos/components/form/repositoryValidation.test.js
@@ -0,0 +1,31 @@
+import * as validator from "./repositoryValidation";
+
+describe("repository name validation", () => {
+ // we don't need rich tests, because they are in validation.test.js
+ it("should validate the name", () => {
+ expect(validator.isNameValid("scm-manager")).toBe(true);
+ });
+
+ it("should fail for old nested repository names", () => {
+ // in v2 this is not allowed
+ expect(validator.isNameValid("scm/manager")).toBe(false);
+ expect(validator.isNameValid("scm/ma/nager")).toBe(false);
+ });
+});
+
+describe("repository contact validation", () => {
+ it("should allow empty contact", () => {
+ expect(validator.isContactValid("")).toBe(true);
+ });
+
+ // we don't need rich tests, because they are in validation.test.js
+ it("should allow real mail addresses", () => {
+ expect(validator.isContactValid("trici.mcmillian@hitchhiker.com")).toBe(
+ true
+ );
+ });
+
+ it("should fail on invalid mail addresses", () => {
+ expect(validator.isContactValid("tricia")).toBe(false);
+ });
+});
diff --git a/scm-ui/src/repos/components/list/RepositoryEntry.js b/scm-ui/src/repos/components/list/RepositoryEntry.js
new file mode 100644
index 0000000000..99d59020ce
--- /dev/null
+++ b/scm-ui/src/repos/components/list/RepositoryEntry.js
@@ -0,0 +1,119 @@
+//@flow
+import React from "react";
+import { Link } from "react-router-dom";
+import injectSheet from "react-jss";
+import type { Repository } from "../../types/Repositories";
+import DateFromNow from "../../../components/DateFromNow";
+import RepositoryEntryLink from "./RepositoryEntryLink";
+import classNames from "classnames";
+
+import icon from "../../../images/blib.jpg";
+
+const styles = {
+ outer: {
+ position: "relative"
+ },
+ overlay: {
+ position: "absolute",
+ left: 0,
+ top: 0,
+ bottom: 0,
+ right: 0
+ },
+ inner: {
+ position: "relative",
+ pointerEvents: "none",
+ zIndex: 1
+ },
+ innerLink: {
+ pointerEvents: "all"
+ }
+};
+
+type Props = {
+ repository: Repository,
+ // context props
+ classes: any
+};
+
+class RepositoryEntry extends React.Component {
+ createLink = (repository: Repository) => {
+ return `/repo/${repository.namespace}/${repository.name}`;
+ };
+
+ renderChangesetsLink = (repository: Repository, repositoryLink: string) => {
+ if (repository._links["changesets"]) {
+ return (
+
+ );
+ }
+ return null;
+ };
+
+ renderSourcesLink = (repository: Repository, repositoryLink: string) => {
+ if (repository._links["sources"]) {
+ return (
+
+ );
+ }
+ return null;
+ };
+
+ renderModifyLink = (repository: Repository, repositoryLink: string) => {
+ if (repository._links["update"]) {
+ return (
+
+ );
+ }
+ return null;
+ };
+
+ render() {
+ const { repository, classes } = this.props;
+ const repositoryLink = this.createLink(repository);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {repository.name}
+
+ {repository.description}
+
+
+
+
+
+
+ );
+ }
+}
+
+export default injectSheet(styles)(RepositoryEntry);
diff --git a/scm-ui/src/repos/components/list/RepositoryEntryLink.js b/scm-ui/src/repos/components/list/RepositoryEntryLink.js
new file mode 100644
index 0000000000..289ec7d326
--- /dev/null
+++ b/scm-ui/src/repos/components/list/RepositoryEntryLink.js
@@ -0,0 +1,34 @@
+//@flow
+import React from "react";
+import { Link } from "react-router-dom";
+import injectSheet from "react-jss";
+import classNames from "classnames";
+
+const styles = {
+ link: {
+ pointerEvents: "all"
+ }
+};
+
+type Props = {
+ to: string,
+ iconClass: string,
+
+ // context props
+ classes: any
+};
+
+class RepositoryEntryLink extends React.Component {
+ render() {
+ const { to, iconClass, classes } = this.props;
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+export default injectSheet(styles)(RepositoryEntryLink);
diff --git a/scm-ui/src/repos/components/list/RepositoryGroupEntry.js b/scm-ui/src/repos/components/list/RepositoryGroupEntry.js
new file mode 100644
index 0000000000..09b3932b28
--- /dev/null
+++ b/scm-ui/src/repos/components/list/RepositoryGroupEntry.js
@@ -0,0 +1,67 @@
+//@flow
+import React from "react";
+import type { RepositoryGroup } from "../../types/Repositories";
+import injectSheet from "react-jss";
+import classNames from "classnames";
+import RepositoryEntry from "./RepositoryEntry";
+
+const styles = {
+ pointer: {
+ cursor: "pointer"
+ },
+ repoGroup: {
+ marginBottom: "1em"
+ }
+};
+
+type Props = {
+ group: RepositoryGroup,
+
+ // context props
+ classes: any
+};
+
+type State = {
+ collapsed: boolean
+};
+
+class RepositoryGroupEntry extends React.Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ collapsed: false
+ };
+ }
+
+ toggleCollapse = () => {
+ this.setState(prevState => ({
+ collapsed: !prevState.collapsed
+ }));
+ };
+
+ render() {
+ const { group, classes } = this.props;
+ const { collapsed } = this.state;
+
+ const icon = collapsed ? "fa-angle-right" : "fa-angle-down";
+ let content = null;
+ if (!collapsed) {
+ content = group.repositories.map((repository, index) => {
+ return ;
+ });
+ }
+ return (
+
+
+
+ {group.name}
+
+
+
+ {content}
+
+ );
+ }
+}
+
+export default injectSheet(styles)(RepositoryGroupEntry);
diff --git a/scm-ui/src/repos/components/list/RepositoryList.js b/scm-ui/src/repos/components/list/RepositoryList.js
new file mode 100644
index 0000000000..367c054990
--- /dev/null
+++ b/scm-ui/src/repos/components/list/RepositoryList.js
@@ -0,0 +1,28 @@
+//@flow
+import React from "react";
+
+import type { Repository } from "../../types/Repositories";
+
+import groupByNamespace from "./groupByNamespace";
+import RepositoryGroupEntry from "./RepositoryGroupEntry";
+
+type Props = {
+ repositories: Repository[]
+};
+
+class RepositoryList extends React.Component {
+ render() {
+ const { repositories } = this.props;
+
+ const groups = groupByNamespace(repositories);
+ return (
+
+ {groups.map(group => {
+ return ;
+ })}
+
+ );
+ }
+}
+
+export default RepositoryList;
diff --git a/scm-ui/src/repos/components/list/groupByNamespace.js b/scm-ui/src/repos/components/list/groupByNamespace.js
new file mode 100644
index 0000000000..825fa5e67b
--- /dev/null
+++ b/scm-ui/src/repos/components/list/groupByNamespace.js
@@ -0,0 +1,39 @@
+// @flow
+import type { Repository, RepositoryGroup } from "../../types/Repositories";
+
+export default function groupByNamespace(
+ repositories: Repository[]
+): RepositoryGroup[] {
+ let groups = {};
+ for (let repository of repositories) {
+ const groupName = repository.namespace;
+
+ let group = groups[groupName];
+ if (!group) {
+ group = {
+ name: groupName,
+ repositories: []
+ };
+ groups[groupName] = group;
+ }
+ group.repositories.push(repository);
+ }
+
+ let groupArray = [];
+ for (let groupName in groups) {
+ const group = groups[groupName];
+ group.repositories.sort(sortByName);
+ groupArray.push(groups[groupName]);
+ }
+ groupArray.sort(sortByName);
+ return groupArray;
+}
+
+function sortByName(a, b) {
+ if (a.name < b.name) {
+ return -1;
+ } else if (a.name > b.name) {
+ return 1;
+ }
+ return 0;
+}
diff --git a/scm-ui/src/repos/components/list/groupByNamespace.test.js b/scm-ui/src/repos/components/list/groupByNamespace.test.js
new file mode 100644
index 0000000000..e7d2ffca93
--- /dev/null
+++ b/scm-ui/src/repos/components/list/groupByNamespace.test.js
@@ -0,0 +1,74 @@
+// @flow
+import groupByNamespace from "./groupByNamespace";
+
+const base = {
+ type: "git",
+ _links: {}
+};
+
+const slartiBlueprintsFjords = {
+ ...base,
+ namespace: "slarti",
+ name: "fjords-blueprints"
+};
+
+const slartiFjords = {
+ ...base,
+ namespace: "slarti",
+ name: "fjords"
+};
+
+const hitchhikerRestand = {
+ ...base,
+ namespace: "hitchhiker",
+ name: "restand"
+};
+const hitchhikerPuzzle42 = {
+ ...base,
+ namespace: "hitchhiker",
+ name: "puzzle42"
+};
+
+const hitchhikerHeartOfGold = {
+ ...base,
+ namespace: "hitchhiker",
+ name: "heartOfGold"
+};
+
+const zaphodMarvinFirmware = {
+ ...base,
+ namespace: "zaphod",
+ name: "marvin-firmware"
+};
+
+it("should group the repositories by their namespace", () => {
+ const repositories = [
+ zaphodMarvinFirmware,
+ slartiBlueprintsFjords,
+ hitchhikerRestand,
+ slartiFjords,
+ hitchhikerHeartOfGold,
+ hitchhikerPuzzle42
+ ];
+
+ const expected = [
+ {
+ name: "hitchhiker",
+ repositories: [
+ hitchhikerHeartOfGold,
+ hitchhikerPuzzle42,
+ hitchhikerRestand
+ ]
+ },
+ {
+ name: "slarti",
+ repositories: [slartiFjords, slartiBlueprintsFjords]
+ },
+ {
+ name: "zaphod",
+ repositories: [zaphodMarvinFirmware]
+ }
+ ];
+
+ expect(groupByNamespace(repositories)).toEqual(expected);
+});
diff --git a/scm-ui/src/repos/components/list/index.js b/scm-ui/src/repos/components/list/index.js
new file mode 100644
index 0000000000..62c264cb7b
--- /dev/null
+++ b/scm-ui/src/repos/components/list/index.js
@@ -0,0 +1,2 @@
+import RepositoryList from "./RepositoryList";
+export default RepositoryList;
diff --git a/scm-ui/src/repos/containers/Create.js b/scm-ui/src/repos/containers/Create.js
new file mode 100644
index 0000000000..0fb41ab2c1
--- /dev/null
+++ b/scm-ui/src/repos/containers/Create.js
@@ -0,0 +1,111 @@
+// @flow
+import React from "react";
+import { connect } from "react-redux";
+import { translate } from "react-i18next";
+import { Page } from "../../components/layout";
+import RepositoryForm from "../components/form";
+import type { RepositoryType } from "../types/RepositoryTypes";
+import {
+ fetchRepositoryTypesIfNeeded,
+ getFetchRepositoryTypesFailure,
+ getRepositoryTypes,
+ isFetchRepositoryTypesPending
+} from "../modules/repositoryTypes";
+import {
+ createRepo,
+ createRepoReset,
+ getCreateRepoFailure,
+ isCreateRepoPending
+} from "../modules/repos";
+import type { Repository } from "../types/Repositories";
+import type { History } from "history";
+
+type Props = {
+ repositoryTypes: RepositoryType[],
+ typesLoading: boolean,
+ createLoading: boolean,
+ error: Error,
+
+ // dispatch functions
+ fetchRepositoryTypesIfNeeded: () => void,
+ createRepo: (Repository, callback: () => void) => void,
+ resetForm: () => void,
+
+ // context props
+ t: string => string,
+ history: History
+};
+
+class Create extends React.Component {
+ componentDidMount() {
+ this.props.resetForm();
+ this.props.fetchRepositoryTypesIfNeeded();
+ }
+
+ repoCreated = () => {
+ const { history } = this.props;
+ history.push("/repos");
+ };
+
+ render() {
+ const {
+ typesLoading,
+ createLoading,
+ repositoryTypes,
+ createRepo,
+ error
+ } = this.props;
+
+ const { t } = this.props;
+ return (
+
+ {
+ createRepo(repo, this.repoCreated);
+ }}
+ />
+
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const repositoryTypes = getRepositoryTypes(state);
+ const typesLoading = isFetchRepositoryTypesPending(state);
+ const createLoading = isCreateRepoPending(state);
+ const error =
+ getFetchRepositoryTypesFailure(state) || getCreateRepoFailure(state);
+ return {
+ repositoryTypes,
+ typesLoading,
+ createLoading,
+ error
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchRepositoryTypesIfNeeded: () => {
+ dispatch(fetchRepositoryTypesIfNeeded());
+ },
+ createRepo: (repository: Repository, callback: () => void) => {
+ dispatch(createRepo(repository, callback));
+ },
+ resetForm: () => {
+ dispatch(createRepoReset());
+ }
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(translate("repos")(Create));
diff --git a/scm-ui/src/repos/containers/Edit.js b/scm-ui/src/repos/containers/Edit.js
new file mode 100644
index 0000000000..201ab3f2a7
--- /dev/null
+++ b/scm-ui/src/repos/containers/Edit.js
@@ -0,0 +1,71 @@
+// @flow
+import React from "react";
+import { connect } from "react-redux";
+import { translate } from "react-i18next";
+import RepositoryForm from "../components/form";
+import type { Repository } from "../types/Repositories";
+import {
+ modifyRepo,
+ isModifyRepoPending,
+ getModifyRepoFailure
+} from "../modules/repos";
+import { withRouter } from "react-router-dom";
+import type { History } from "history";
+import ErrorNotification from "../../components/ErrorNotification";
+
+type Props = {
+ repository: Repository,
+ modifyRepo: (Repository, () => void) => void,
+ loading: boolean,
+ error: Error,
+
+ // context props
+ t: string => string,
+ history: History
+};
+
+class Edit extends React.Component {
+ repoModified = () => {
+ const { history, repository } = this.props;
+ history.push(`/repo/${repository.namespace}/${repository.name}`);
+ };
+
+ render() {
+ const { loading, error } = this.props;
+ return (
+
+
+ {
+ this.props.modifyRepo(repo, this.repoModified);
+ }}
+ />
+
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const { namespace, name } = ownProps.repository;
+ const loading = isModifyRepoPending(state, namespace, name);
+ const error = getModifyRepoFailure(state, namespace, name);
+ return {
+ loading,
+ error
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ modifyRepo: (repo: Repository, callback: () => void) => {
+ dispatch(modifyRepo(repo, callback));
+ }
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(translate("repos")(withRouter(Edit)));
diff --git a/scm-ui/src/repos/containers/Overview.js b/scm-ui/src/repos/containers/Overview.js
new file mode 100644
index 0000000000..f4b49ed8d2
--- /dev/null
+++ b/scm-ui/src/repos/containers/Overview.js
@@ -0,0 +1,143 @@
+// @flow
+import React from "react";
+
+import type { RepositoryCollection } from "../types/Repositories";
+
+import { connect } from "react-redux";
+import {
+ fetchRepos,
+ fetchReposByLink,
+ fetchReposByPage,
+ getFetchReposFailure,
+ getRepositoryCollection,
+ isAbleToCreateRepos,
+ isFetchReposPending
+} from "../modules/repos";
+import { translate } from "react-i18next";
+import { Page } from "../../components/layout";
+import RepositoryList from "../components/list";
+import Paginator from "../../components/Paginator";
+import { withRouter } from "react-router-dom";
+import type { History } from "history";
+import CreateButton from "../../components/buttons/CreateButton";
+
+type Props = {
+ page: number,
+ collection: RepositoryCollection,
+ loading: boolean,
+ error: Error,
+ showCreateButton: boolean,
+
+ // dispatched functions
+ fetchRepos: () => void,
+ fetchReposByPage: number => void,
+ fetchReposByLink: string => void,
+
+ // context props
+ t: string => string,
+ history: History
+};
+
+class Overview extends React.Component {
+ componentDidMount() {
+ this.props.fetchReposByPage(this.props.page);
+ }
+
+ /**
+ * reflect page transitions in the uri
+ */
+ componentDidUpdate() {
+ const { page, collection } = this.props;
+ if (collection) {
+ // backend starts paging by 0
+ const statePage: number = collection.page + 1;
+ if (page !== statePage) {
+ this.props.history.push(`/repos/${statePage}`);
+ }
+ }
+ }
+
+ render() {
+ const { error, loading, t } = this.props;
+ return (
+
+ {this.renderList()}
+
+ );
+ }
+
+ renderList() {
+ const { collection, fetchReposByLink } = this.props;
+ if (collection) {
+ return (
+
+
+
+ {this.renderCreateButton()}
+
+ );
+ }
+ return null;
+ }
+
+ renderCreateButton() {
+ const { showCreateButton, t } = this.props;
+ if (showCreateButton) {
+ return (
+
+ );
+ }
+ return null;
+ }
+}
+
+const getPageFromProps = props => {
+ let page = props.match.params.page;
+ if (page) {
+ page = parseInt(page, 10);
+ } else {
+ page = 1;
+ }
+ return page;
+};
+
+const mapStateToProps = (state, ownProps) => {
+ const page = getPageFromProps(ownProps);
+ const collection = getRepositoryCollection(state);
+ const loading = isFetchReposPending(state);
+ const error = getFetchReposFailure(state);
+ const showCreateButton = isAbleToCreateRepos(state);
+ return {
+ page,
+ collection,
+ loading,
+ error,
+ showCreateButton
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchRepos: () => {
+ dispatch(fetchRepos());
+ },
+ fetchReposByPage: (page: number) => {
+ dispatch(fetchReposByPage(page));
+ },
+ fetchReposByLink: (link: string) => {
+ dispatch(fetchReposByLink(link));
+ }
+ };
+};
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(translate("repos")(withRouter(Overview)));
diff --git a/scm-ui/src/repos/containers/RepositoryRoot.js b/scm-ui/src/repos/containers/RepositoryRoot.js
new file mode 100644
index 0000000000..820aedf964
--- /dev/null
+++ b/scm-ui/src/repos/containers/RepositoryRoot.js
@@ -0,0 +1,147 @@
+//@flow
+import React from "react";
+import {
+ deleteRepo,
+ fetchRepo,
+ getFetchRepoFailure,
+ getRepository,
+ isFetchRepoPending
+} from "../modules/repos";
+import { connect } from "react-redux";
+import { Route } from "react-router-dom";
+import type { Repository } from "../types/Repositories";
+import { Page } from "../../components/layout";
+import Loading from "../../components/Loading";
+import ErrorPage from "../../components/ErrorPage";
+import { translate } from "react-i18next";
+import { Navigation, NavLink, Section } from "../../components/navigation";
+import RepositoryDetails from "../components/RepositoryDetails";
+import DeleteNavAction from "../components/DeleteNavAction";
+import Edit from "../containers/Edit";
+
+import type { History } from "history";
+import EditNavLink from "../components/EditNavLink";
+
+type Props = {
+ namespace: string,
+ name: string,
+ repository: Repository,
+ loading: boolean,
+ error: Error,
+
+ // dispatch functions
+ fetchRepo: (namespace: string, name: string) => void,
+ deleteRepo: (repository: Repository, () => void) => void,
+
+ // context props
+ t: string => string,
+ history: History,
+ match: any
+};
+
+class RepositoryRoot extends React.Component {
+ componentDidMount() {
+ const { fetchRepo, namespace, name } = this.props;
+
+ fetchRepo(namespace, name);
+ }
+
+ stripEndingSlash = (url: string) => {
+ if (url.endsWith("/")) {
+ return url.substring(0, url.length - 2);
+ }
+ return url;
+ };
+
+ matchedUrl = () => {
+ return this.stripEndingSlash(this.props.match.url);
+ };
+
+ deleted = () => {
+ this.props.history.push("/repos");
+ };
+
+ delete = (repository: Repository) => {
+ this.props.deleteRepo(repository, this.deleted);
+ };
+
+ render() {
+ const { loading, error, repository, t } = this.props;
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!repository || loading) {
+ return ;
+ }
+
+ const url = this.matchedUrl();
+
+ return (
+
+
+
+ }
+ />
+ }
+ />
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const { namespace, name } = ownProps.match.params;
+ const repository = getRepository(state, namespace, name);
+ const loading = isFetchRepoPending(state, namespace, name);
+ const error = getFetchRepoFailure(state, namespace, name);
+ return {
+ namespace,
+ name,
+ repository,
+ loading,
+ error
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchRepo: (namespace: string, name: string) => {
+ dispatch(fetchRepo(namespace, name));
+ },
+ deleteRepo: (repository: Repository, callback: () => void) => {
+ dispatch(deleteRepo(repository, callback));
+ }
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(translate("repos")(RepositoryRoot));
diff --git a/scm-ui/src/repos/modules/repos.js b/scm-ui/src/repos/modules/repos.js
new file mode 100644
index 0000000000..da0a69850e
--- /dev/null
+++ b/scm-ui/src/repos/modules/repos.js
@@ -0,0 +1,447 @@
+// @flow
+import { apiClient } from "../../apiclient";
+import * as types from "../../modules/types";
+import type { Action } from "../../types/Action";
+import type { Repository, RepositoryCollection } from "../types/Repositories";
+import { isPending } from "../../modules/pending";
+import { getFailure } from "../../modules/failure";
+
+export const FETCH_REPOS = "scm/repos/FETCH_REPOS";
+export const FETCH_REPOS_PENDING = `${FETCH_REPOS}_${types.PENDING_SUFFIX}`;
+export const FETCH_REPOS_SUCCESS = `${FETCH_REPOS}_${types.SUCCESS_SUFFIX}`;
+export const FETCH_REPOS_FAILURE = `${FETCH_REPOS}_${types.FAILURE_SUFFIX}`;
+
+export const FETCH_REPO = "scm/repos/FETCH_REPO";
+export const FETCH_REPO_PENDING = `${FETCH_REPO}_${types.PENDING_SUFFIX}`;
+export const FETCH_REPO_SUCCESS = `${FETCH_REPO}_${types.SUCCESS_SUFFIX}`;
+export const FETCH_REPO_FAILURE = `${FETCH_REPO}_${types.FAILURE_SUFFIX}`;
+
+export const CREATE_REPO = "scm/repos/CREATE_REPO";
+export const CREATE_REPO_PENDING = `${CREATE_REPO}_${types.PENDING_SUFFIX}`;
+export const CREATE_REPO_SUCCESS = `${CREATE_REPO}_${types.SUCCESS_SUFFIX}`;
+export const CREATE_REPO_FAILURE = `${CREATE_REPO}_${types.FAILURE_SUFFIX}`;
+export const CREATE_REPO_RESET = `${CREATE_REPO}_${types.RESET_SUFFIX}`;
+
+export const MODIFY_REPO = "scm/repos/MODIFY_REPO";
+export const MODIFY_REPO_PENDING = `${MODIFY_REPO}_${types.PENDING_SUFFIX}`;
+export const MODIFY_REPO_SUCCESS = `${MODIFY_REPO}_${types.SUCCESS_SUFFIX}`;
+export const MODIFY_REPO_FAILURE = `${MODIFY_REPO}_${types.FAILURE_SUFFIX}`;
+
+export const DELETE_REPO = "scm/repos/DELETE_REPO";
+export const DELETE_REPO_PENDING = `${DELETE_REPO}_${types.PENDING_SUFFIX}`;
+export const DELETE_REPO_SUCCESS = `${DELETE_REPO}_${types.SUCCESS_SUFFIX}`;
+export const DELETE_REPO_FAILURE = `${DELETE_REPO}_${types.FAILURE_SUFFIX}`;
+
+const REPOS_URL = "repositories";
+
+const CONTENT_TYPE = "application/vnd.scmm-repository+json;v=2";
+
+// fetch repos
+
+const SORT_BY = "sortBy=namespaceAndName";
+
+export function fetchRepos() {
+ return fetchReposByLink(REPOS_URL);
+}
+
+export function fetchReposByPage(page: number) {
+ return fetchReposByLink(`${REPOS_URL}?page=${page - 1}`);
+}
+
+function appendSortByLink(url: string) {
+ if (url.includes(SORT_BY)) {
+ return url;
+ }
+ let urlWithSortBy = url;
+ if (url.includes("?")) {
+ urlWithSortBy += "&";
+ } else {
+ urlWithSortBy += "?";
+ }
+ return urlWithSortBy + SORT_BY;
+}
+
+export function fetchReposByLink(link: string) {
+ const url = appendSortByLink(link);
+ return function(dispatch: any) {
+ dispatch(fetchReposPending());
+ return apiClient
+ .get(url)
+ .then(response => response.json())
+ .then(repositories => {
+ dispatch(fetchReposSuccess(repositories));
+ })
+ .catch(err => {
+ dispatch(fetchReposFailure(err));
+ });
+ };
+}
+
+export function fetchReposPending(): Action {
+ return {
+ type: FETCH_REPOS_PENDING
+ };
+}
+
+export function fetchReposSuccess(repositories: RepositoryCollection): Action {
+ return {
+ type: FETCH_REPOS_SUCCESS,
+ payload: repositories
+ };
+}
+
+export function fetchReposFailure(err: Error): Action {
+ return {
+ type: FETCH_REPOS_FAILURE,
+ payload: err
+ };
+}
+
+// fetch repo
+
+export function fetchRepo(namespace: string, name: string) {
+ return function(dispatch: any) {
+ dispatch(fetchRepoPending(namespace, name));
+ return apiClient
+ .get(`${REPOS_URL}/${namespace}/${name}`)
+ .then(response => response.json())
+ .then(repository => {
+ dispatch(fetchRepoSuccess(repository));
+ })
+ .catch(err => {
+ dispatch(fetchRepoFailure(namespace, name, err));
+ });
+ };
+}
+
+export function fetchRepoPending(namespace: string, name: string): Action {
+ return {
+ type: FETCH_REPO_PENDING,
+ payload: {
+ namespace,
+ name
+ },
+ itemId: namespace + "/" + name
+ };
+}
+
+export function fetchRepoSuccess(repository: Repository): Action {
+ return {
+ type: FETCH_REPO_SUCCESS,
+ payload: repository,
+ itemId: createIdentifier(repository)
+ };
+}
+
+export function fetchRepoFailure(
+ namespace: string,
+ name: string,
+ error: Error
+): Action {
+ return {
+ type: FETCH_REPO_FAILURE,
+ payload: {
+ namespace,
+ name,
+ error
+ },
+ itemId: namespace + "/" + name
+ };
+}
+
+// create repo
+
+export function createRepo(repository: Repository, callback?: () => void) {
+ return function(dispatch: any) {
+ dispatch(createRepoPending());
+ return apiClient
+ .post(REPOS_URL, repository, CONTENT_TYPE)
+ .then(() => {
+ dispatch(createRepoSuccess());
+ if (callback) {
+ callback();
+ }
+ })
+ .catch(err => {
+ dispatch(createRepoFailure(err));
+ });
+ };
+}
+
+export function createRepoPending(): Action {
+ return {
+ type: CREATE_REPO_PENDING
+ };
+}
+
+export function createRepoSuccess(): Action {
+ return {
+ type: CREATE_REPO_SUCCESS
+ };
+}
+
+export function createRepoFailure(err: Error): Action {
+ return {
+ type: CREATE_REPO_FAILURE,
+ payload: err
+ };
+}
+
+export function createRepoReset(): Action {
+ return {
+ type: CREATE_REPO_RESET
+ };
+}
+
+// modify
+
+export function modifyRepo(repository: Repository, callback?: () => void) {
+ return function(dispatch: any) {
+ dispatch(modifyRepoPending(repository));
+
+ return apiClient
+ .put(repository._links.update.href, repository, CONTENT_TYPE)
+ .then(() => {
+ dispatch(modifyRepoSuccess(repository));
+ if (callback) {
+ callback();
+ }
+ })
+ .catch(cause => {
+ const error = new Error(`failed to modify repo: ${cause.message}`);
+ dispatch(modifyRepoFailure(repository, error));
+ });
+ };
+}
+
+export function modifyRepoPending(repository: Repository): Action {
+ return {
+ type: MODIFY_REPO_PENDING,
+ payload: repository,
+ itemId: createIdentifier(repository)
+ };
+}
+
+export function modifyRepoSuccess(repository: Repository): Action {
+ return {
+ type: MODIFY_REPO_SUCCESS,
+ payload: repository,
+ itemId: createIdentifier(repository)
+ };
+}
+
+export function modifyRepoFailure(
+ repository: Repository,
+ error: Error
+): Action {
+ return {
+ type: MODIFY_REPO_FAILURE,
+ payload: { error, repository },
+ itemId: createIdentifier(repository)
+ };
+}
+
+// delete
+
+export function deleteRepo(repository: Repository, callback?: () => void) {
+ return function(dispatch: any) {
+ dispatch(deleteRepoPending(repository));
+ return apiClient
+ .delete(repository._links.delete.href)
+ .then(() => {
+ dispatch(deleteRepoSuccess(repository));
+ if (callback) {
+ callback();
+ }
+ })
+ .catch(err => {
+ dispatch(deleteRepoFailure(repository, err));
+ });
+ };
+}
+
+export function deleteRepoPending(repository: Repository): Action {
+ return {
+ type: DELETE_REPO_PENDING,
+ payload: repository,
+ itemId: createIdentifier(repository)
+ };
+}
+
+export function deleteRepoSuccess(repository: Repository): Action {
+ return {
+ type: DELETE_REPO_SUCCESS,
+ payload: repository,
+ itemId: createIdentifier(repository)
+ };
+}
+
+export function deleteRepoFailure(
+ repository: Repository,
+ error: Error
+): Action {
+ return {
+ type: DELETE_REPO_FAILURE,
+ payload: {
+ error,
+ repository
+ },
+ itemId: createIdentifier(repository)
+ };
+}
+
+// reducer
+
+function createIdentifier(repository: Repository) {
+ return repository.namespace + "/" + repository.name;
+}
+
+function normalizeByNamespaceAndName(
+ repositoryCollection: RepositoryCollection
+) {
+ const names = [];
+ const byNames = {};
+ for (const repository of repositoryCollection._embedded.repositories) {
+ const identifier = createIdentifier(repository);
+ names.push(identifier);
+ byNames[identifier] = repository;
+ }
+ return {
+ list: {
+ ...repositoryCollection,
+ _embedded: {
+ repositories: names
+ }
+ },
+ byNames: byNames
+ };
+}
+
+const reducerByNames = (state: Object, repository: Repository) => {
+ const identifier = createIdentifier(repository);
+ const newState = {
+ ...state,
+ byNames: {
+ ...state.byNames,
+ [identifier]: repository
+ }
+ };
+
+ return newState;
+};
+
+export default function reducer(
+ state: Object = {},
+ action: Action = { type: "UNKNOWN" }
+): Object {
+ if (!action.payload) {
+ return state;
+ }
+
+ switch (action.type) {
+ case FETCH_REPOS_SUCCESS:
+ return normalizeByNamespaceAndName(action.payload);
+ case MODIFY_REPO_SUCCESS:
+ return reducerByNames(state, action.payload);
+ case FETCH_REPO_SUCCESS:
+ return reducerByNames(state, action.payload);
+ default:
+ return state;
+ }
+}
+
+// selectors
+
+export function getRepositoryCollection(state: Object) {
+ if (state.repos && state.repos.list && state.repos.byNames) {
+ const repositories = [];
+ for (let repositoryName of state.repos.list._embedded.repositories) {
+ repositories.push(state.repos.byNames[repositoryName]);
+ }
+ return {
+ ...state.repos.list,
+ _embedded: {
+ repositories
+ }
+ };
+ }
+}
+
+export function isFetchReposPending(state: Object) {
+ return isPending(state, FETCH_REPOS);
+}
+
+export function getFetchReposFailure(state: Object) {
+ return getFailure(state, FETCH_REPOS);
+}
+
+export function getRepository(state: Object, namespace: string, name: string) {
+ if (state.repos && state.repos.byNames) {
+ return state.repos.byNames[namespace + "/" + name];
+ }
+}
+
+export function isFetchRepoPending(
+ state: Object,
+ namespace: string,
+ name: string
+) {
+ return isPending(state, FETCH_REPO, namespace + "/" + name);
+}
+
+export function getFetchRepoFailure(
+ state: Object,
+ namespace: string,
+ name: string
+) {
+ return getFailure(state, FETCH_REPO, namespace + "/" + name);
+}
+
+export function isAbleToCreateRepos(state: Object) {
+ return !!(
+ state.repos &&
+ state.repos.list &&
+ state.repos.list._links &&
+ state.repos.list._links.create
+ );
+}
+
+export function isCreateRepoPending(state: Object) {
+ return isPending(state, CREATE_REPO);
+}
+
+export function getCreateRepoFailure(state: Object) {
+ return getFailure(state, CREATE_REPO);
+}
+
+export function isModifyRepoPending(
+ state: Object,
+ namespace: string,
+ name: string
+) {
+ return isPending(state, MODIFY_REPO, namespace + "/" + name);
+}
+
+export function getModifyRepoFailure(
+ state: Object,
+ namespace: string,
+ name: string
+) {
+ return getFailure(state, MODIFY_REPO, namespace + "/" + name);
+}
+
+export function isDeleteRepoPending(
+ state: Object,
+ namespace: string,
+ name: string
+) {
+ return isPending(state, DELETE_REPO, namespace + "/" + name);
+}
+
+export function getDeleteRepoFailure(
+ state: Object,
+ namespace: string,
+ name: string
+) {
+ return getFailure(state, DELETE_REPO, namespace + "/" + name);
+}
diff --git a/scm-ui/src/repos/modules/repos.test.js b/scm-ui/src/repos/modules/repos.test.js
new file mode 100644
index 0000000000..75efb69ad7
--- /dev/null
+++ b/scm-ui/src/repos/modules/repos.test.js
@@ -0,0 +1,795 @@
+// @flow
+import configureMockStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import fetchMock from "fetch-mock";
+import reducer, {
+ FETCH_REPOS_PENDING,
+ FETCH_REPOS_SUCCESS,
+ fetchRepos,
+ FETCH_REPOS_FAILURE,
+ fetchReposSuccess,
+ getRepositoryCollection,
+ FETCH_REPOS,
+ isFetchReposPending,
+ getFetchReposFailure,
+ fetchReposByLink,
+ fetchReposByPage,
+ FETCH_REPO,
+ fetchRepo,
+ FETCH_REPO_PENDING,
+ FETCH_REPO_SUCCESS,
+ FETCH_REPO_FAILURE,
+ fetchRepoSuccess,
+ getRepository,
+ isFetchRepoPending,
+ getFetchRepoFailure,
+ CREATE_REPO_PENDING,
+ CREATE_REPO_SUCCESS,
+ createRepo,
+ CREATE_REPO_FAILURE,
+ isCreateRepoPending,
+ CREATE_REPO,
+ getCreateRepoFailure,
+ isAbleToCreateRepos,
+ DELETE_REPO,
+ DELETE_REPO_SUCCESS,
+ deleteRepo,
+ DELETE_REPO_PENDING,
+ DELETE_REPO_FAILURE,
+ isDeleteRepoPending,
+ getDeleteRepoFailure,
+ modifyRepo,
+ MODIFY_REPO_PENDING,
+ MODIFY_REPO_SUCCESS,
+ MODIFY_REPO_FAILURE,
+ MODIFY_REPO,
+ isModifyRepoPending,
+ getModifyRepoFailure,
+ modifyRepoSuccess
+} from "./repos";
+import type { Repository, RepositoryCollection } from "../types/Repositories";
+
+const hitchhikerPuzzle42: Repository = {
+ contact: "fourtytwo@hitchhiker.com",
+ creationDate: "2018-07-31T08:58:45.961Z",
+ description: "the answer to life the universe and everything",
+ namespace: "hitchhiker",
+ name: "puzzle42",
+ type: "svn",
+ _links: {
+ self: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42"
+ },
+ delete: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42"
+ },
+ update: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42"
+ },
+ permissions: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/permissions/"
+ },
+ tags: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/tags/"
+ },
+ branches: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/branches/"
+ },
+ changesets: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/changesets/"
+ },
+ sources: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/puzzle42/sources/"
+ }
+ }
+};
+
+const hitchhikerRestatend: Repository = {
+ contact: "restatend@hitchhiker.com",
+ creationDate: "2018-07-31T08:58:32.803Z",
+ description: "restaurant at the end of the universe",
+ namespace: "hitchhiker",
+ name: "restatend",
+ archived: false,
+ type: "git",
+ _links: {
+ self: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend"
+ },
+ delete: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend"
+ },
+ update: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend"
+ },
+ permissions: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/permissions/"
+ },
+ tags: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/tags/"
+ },
+ branches: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/branches/"
+ },
+ changesets: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/changesets/"
+ },
+ sources: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/hitchhiker/restatend/sources/"
+ }
+ }
+};
+
+const slartiFjords: Repository = {
+ contact: "slartibartfast@hitchhiker.com",
+ description: "My award-winning fjords from the Norwegian coast",
+ namespace: "slarti",
+ name: "fjords",
+ type: "hg",
+ creationDate: "2018-07-31T08:59:05.653Z",
+ _links: {
+ self: {
+ href: "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords"
+ },
+ delete: {
+ href: "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords"
+ },
+ update: {
+ href: "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords"
+ },
+ permissions: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/permissions/"
+ },
+ tags: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/tags/"
+ },
+ branches: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/branches/"
+ },
+ changesets: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/changesets/"
+ },
+ sources: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords/sources/"
+ }
+ }
+};
+
+const repositoryCollection: RepositoryCollection = {
+ page: 0,
+ pageTotal: 1,
+ _links: {
+ self: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
+ },
+ first: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
+ },
+ last: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
+ },
+ create: {
+ href: "http://localhost:8081/scm/api/rest/v2/repositories/"
+ }
+ },
+ _embedded: {
+ repositories: [hitchhikerPuzzle42, hitchhikerRestatend, slartiFjords]
+ }
+};
+
+const repositoryCollectionWithNames: RepositoryCollection = {
+ page: 0,
+ pageTotal: 1,
+ _links: {
+ self: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
+ },
+ first: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
+ },
+ last: {
+ href:
+ "http://localhost:8081/scm/api/rest/v2/repositories/?page=0&pageSize=10"
+ },
+ create: {
+ href: "http://localhost:8081/scm/api/rest/v2/repositories/"
+ }
+ },
+ _embedded: {
+ repositories: [
+ "hitchhiker/puzzle42",
+ "hitchhiker/restatend",
+ "slarti/fjords"
+ ]
+ }
+};
+
+describe("repos fetch", () => {
+ const REPOS_URL = "/scm/api/rest/v2/repositories";
+ const SORT = "sortBy=namespaceAndName";
+ const REPOS_URL_WITH_SORT = REPOS_URL + "?" + SORT;
+ const mockStore = configureMockStore([thunk]);
+
+ afterEach(() => {
+ fetchMock.reset();
+ fetchMock.restore();
+ });
+
+ it("should successfully fetch repos", () => {
+ fetchMock.getOnce(REPOS_URL_WITH_SORT, repositoryCollection);
+
+ const expectedActions = [
+ { type: FETCH_REPOS_PENDING },
+ {
+ type: FETCH_REPOS_SUCCESS,
+ payload: repositoryCollection
+ }
+ ];
+
+ const store = mockStore({});
+ return store.dispatch(fetchRepos()).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should successfully fetch page 42", () => {
+ const url = REPOS_URL + "?page=42&" + SORT;
+ fetchMock.getOnce(url, repositoryCollection);
+
+ const expectedActions = [
+ { type: FETCH_REPOS_PENDING },
+ {
+ type: FETCH_REPOS_SUCCESS,
+ payload: repositoryCollection
+ }
+ ];
+
+ const store = mockStore({});
+
+ return store.dispatch(fetchReposByPage(43)).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should successfully fetch repos from link", () => {
+ fetchMock.getOnce(
+ REPOS_URL + "?" + SORT + "&page=42",
+ repositoryCollection
+ );
+
+ const expectedActions = [
+ { type: FETCH_REPOS_PENDING },
+ {
+ type: FETCH_REPOS_SUCCESS,
+ payload: repositoryCollection
+ }
+ ];
+
+ const store = mockStore({});
+ return store
+ .dispatch(
+ fetchReposByLink("/repositories?sortBy=namespaceAndName&page=42")
+ )
+ .then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should append sortby parameter and successfully fetch repos from link", () => {
+ fetchMock.getOnce(
+ "/scm/api/rest/v2/repositories?one=1&sortBy=namespaceAndName",
+ repositoryCollection
+ );
+
+ const expectedActions = [
+ { type: FETCH_REPOS_PENDING },
+ {
+ type: FETCH_REPOS_SUCCESS,
+ payload: repositoryCollection
+ }
+ ];
+
+ const store = mockStore({});
+
+ return store.dispatch(fetchReposByLink("/repositories?one=1")).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should dispatch FETCH_REPOS_FAILURE, it the request fails", () => {
+ fetchMock.getOnce(REPOS_URL_WITH_SORT, {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(fetchRepos()).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(FETCH_REPOS_PENDING);
+ expect(actions[1].type).toEqual(FETCH_REPOS_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should successfully fetch repo slarti/fjords", () => {
+ fetchMock.getOnce(REPOS_URL + "/slarti/fjords", slartiFjords);
+
+ const expectedActions = [
+ {
+ type: FETCH_REPO_PENDING,
+ payload: {
+ namespace: "slarti",
+ name: "fjords"
+ },
+ itemId: "slarti/fjords"
+ },
+ {
+ type: FETCH_REPO_SUCCESS,
+ payload: slartiFjords,
+ itemId: "slarti/fjords"
+ }
+ ];
+
+ const store = mockStore({});
+ return store.dispatch(fetchRepo("slarti", "fjords")).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should dispatch FETCH_REPO_FAILURE, it the request for slarti/fjords fails", () => {
+ fetchMock.getOnce(REPOS_URL + "/slarti/fjords", {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(fetchRepo("slarti", "fjords")).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(FETCH_REPO_PENDING);
+ expect(actions[1].type).toEqual(FETCH_REPO_FAILURE);
+ expect(actions[1].payload.namespace).toBe("slarti");
+ expect(actions[1].payload.name).toBe("fjords");
+ expect(actions[1].payload.error).toBeDefined();
+ expect(actions[1].itemId).toBe("slarti/fjords");
+ });
+ });
+
+ it("should successfully create repo slarti/fjords", () => {
+ fetchMock.postOnce(REPOS_URL, {
+ status: 201
+ });
+
+ const expectedActions = [
+ {
+ type: CREATE_REPO_PENDING
+ },
+ {
+ type: CREATE_REPO_SUCCESS
+ }
+ ];
+
+ const store = mockStore({});
+ return store.dispatch(createRepo(slartiFjords)).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should successfully create repo slarti/fjords and call the callback", () => {
+ fetchMock.postOnce(REPOS_URL, {
+ status: 201
+ });
+
+ let callMe = "not yet";
+
+ const callback = () => {
+ callMe = "yeah";
+ };
+
+ const store = mockStore({});
+ return store.dispatch(createRepo(slartiFjords, callback)).then(() => {
+ expect(callMe).toBe("yeah");
+ });
+ });
+
+ it("should disapatch failure if server returns status code 500", () => {
+ fetchMock.postOnce(REPOS_URL, {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(createRepo(slartiFjords)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(CREATE_REPO_PENDING);
+ expect(actions[1].type).toEqual(CREATE_REPO_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should successfully delete repo slarti/fjords", () => {
+ fetchMock.delete(
+ "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords",
+ {
+ status: 204
+ }
+ );
+
+ const expectedActions = [
+ {
+ type: DELETE_REPO_PENDING,
+ payload: slartiFjords,
+ itemId: "slarti/fjords"
+ },
+ {
+ type: DELETE_REPO_SUCCESS,
+ payload: slartiFjords,
+ itemId: "slarti/fjords"
+ }
+ ];
+
+ const store = mockStore({});
+ return store.dispatch(deleteRepo(slartiFjords)).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should successfully delete repo slarti/fjords and call the callback", () => {
+ fetchMock.delete(
+ "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords",
+ {
+ status: 204
+ }
+ );
+
+ let callMe = "not yet";
+
+ const callback = () => {
+ callMe = "yeah";
+ };
+
+ const store = mockStore({});
+ return store.dispatch(deleteRepo(slartiFjords, callback)).then(() => {
+ expect(callMe).toBe("yeah");
+ });
+ });
+
+ it("should disapatch failure on delete, if server returns status code 500", () => {
+ fetchMock.delete(
+ "http://localhost:8081/scm/api/rest/v2/repositories/slarti/fjords",
+ {
+ status: 500
+ }
+ );
+
+ const store = mockStore({});
+ return store.dispatch(deleteRepo(slartiFjords)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(DELETE_REPO_PENDING);
+ expect(actions[1].type).toEqual(DELETE_REPO_FAILURE);
+ expect(actions[1].payload.repository).toBe(slartiFjords);
+ expect(actions[1].payload.error).toBeDefined();
+ });
+ });
+
+ it("should successfully modify slarti/fjords repo", () => {
+ fetchMock.putOnce(slartiFjords._links.update.href, {
+ status: 204
+ });
+
+ let editedFjords = { ...slartiFjords };
+ editedFjords.description = "coast of africa";
+
+ const store = mockStore({});
+
+ return store.dispatch(modifyRepo(editedFjords)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
+ expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS);
+ });
+ });
+
+ it("should successfully modify slarti/fjords repo and call the callback", () => {
+ fetchMock.putOnce(slartiFjords._links.update.href, {
+ status: 204
+ });
+
+ let editedFjords = { ...slartiFjords };
+ editedFjords.description = "coast of africa";
+
+ const store = mockStore({});
+
+ let called = false;
+ const callback = () => {
+ called = true;
+ };
+
+ return store.dispatch(modifyRepo(editedFjords, callback)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
+ expect(actions[1].type).toEqual(MODIFY_REPO_SUCCESS);
+ expect(called).toBe(true);
+ });
+ });
+
+ it("should fail modifying on HTTP 500", () => {
+ fetchMock.putOnce(slartiFjords._links.update.href, {
+ status: 500
+ });
+
+ let editedFjords = { ...slartiFjords };
+ editedFjords.description = "coast of africa";
+
+ const store = mockStore({});
+
+ return store.dispatch(modifyRepo(editedFjords)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(MODIFY_REPO_PENDING);
+ expect(actions[1].type).toEqual(MODIFY_REPO_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+});
+
+describe("repos reducer", () => {
+ it("should return empty object, if state and action is undefined", () => {
+ expect(reducer()).toEqual({});
+ });
+
+ it("should return the same state, if the action is undefined", () => {
+ const state = { x: true };
+ expect(reducer(state)).toBe(state);
+ });
+
+ it("should return the same state, if the action is unknown to the reducer", () => {
+ const state = { x: true };
+ expect(reducer(state, { type: "EL_SPECIALE" })).toBe(state);
+ });
+
+ it("should store the repositories by it's namespace and name on FETCH_REPOS_SUCCESS", () => {
+ const newState = reducer({}, fetchReposSuccess(repositoryCollection));
+ expect(newState.list.page).toBe(0);
+ expect(newState.list.pageTotal).toBe(1);
+ expect(newState.list._embedded.repositories).toEqual([
+ "hitchhiker/puzzle42",
+ "hitchhiker/restatend",
+ "slarti/fjords"
+ ]);
+
+ expect(newState.byNames["hitchhiker/puzzle42"]).toBe(hitchhikerPuzzle42);
+ expect(newState.byNames["hitchhiker/restatend"]).toBe(hitchhikerRestatend);
+ expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords);
+ });
+
+ it("should store the repo at byNames", () => {
+ const newState = reducer({}, fetchRepoSuccess(slartiFjords));
+ expect(newState.byNames["slarti/fjords"]).toBe(slartiFjords);
+ });
+
+ it("should update reposByNames", () => {
+ const oldState = {
+ byNames: {
+ "slarti/fjords": slartiFjords
+ }
+ };
+ let slartiFjordsEdited = { ...slartiFjords };
+ slartiFjordsEdited.description = "I bless the rains down in Africa";
+ const newState = reducer(oldState, modifyRepoSuccess(slartiFjordsEdited));
+ expect(newState.byNames["slarti/fjords"]).toEqual(slartiFjordsEdited);
+ });
+});
+
+describe("repos selectors", () => {
+ const error = new Error("something goes wrong");
+
+ it("should return the repositories collection", () => {
+ const state = {
+ repos: {
+ list: repositoryCollectionWithNames,
+ byNames: {
+ "hitchhiker/puzzle42": hitchhikerPuzzle42,
+ "hitchhiker/restatend": hitchhikerRestatend,
+ "slarti/fjords": slartiFjords
+ }
+ }
+ };
+
+ const collection = getRepositoryCollection(state);
+ expect(collection).toEqual(repositoryCollection);
+ });
+
+ it("should return true, when fetch repos is pending", () => {
+ const state = {
+ pending: {
+ [FETCH_REPOS]: true
+ }
+ };
+ expect(isFetchReposPending(state)).toEqual(true);
+ });
+
+ it("should return false, when fetch repos is not pending", () => {
+ expect(isFetchReposPending({})).toEqual(false);
+ });
+
+ it("should return error when fetch repos did fail", () => {
+ const state = {
+ failure: {
+ [FETCH_REPOS]: error
+ }
+ };
+ expect(getFetchReposFailure(state)).toEqual(error);
+ });
+
+ it("should return undefined when fetch repos did not fail", () => {
+ expect(getFetchReposFailure({})).toBe(undefined);
+ });
+
+ it("should return the repository collection", () => {
+ const state = {
+ repos: {
+ byNames: {
+ "slarti/fjords": slartiFjords
+ }
+ }
+ };
+
+ const repository = getRepository(state, "slarti", "fjords");
+ expect(repository).toEqual(slartiFjords);
+ });
+
+ it("should return true, when fetch repo is pending", () => {
+ const state = {
+ pending: {
+ [FETCH_REPO + "/slarti/fjords"]: true
+ }
+ };
+ expect(isFetchRepoPending(state, "slarti", "fjords")).toEqual(true);
+ });
+
+ it("should return false, when fetch repo is not pending", () => {
+ expect(isFetchRepoPending({}, "slarti", "fjords")).toEqual(false);
+ });
+
+ it("should return error when fetch repo did fail", () => {
+ const state = {
+ failure: {
+ [FETCH_REPO + "/slarti/fjords"]: error
+ }
+ };
+ expect(getFetchRepoFailure(state, "slarti", "fjords")).toEqual(error);
+ });
+
+ it("should return undefined when fetch repo did not fail", () => {
+ expect(getFetchRepoFailure({}, "slarti", "fjords")).toBe(undefined);
+ });
+
+ // create
+
+ it("should return true, when create repo is pending", () => {
+ const state = {
+ pending: {
+ [CREATE_REPO]: true
+ }
+ };
+ expect(isCreateRepoPending(state)).toEqual(true);
+ });
+
+ it("should return false, when create repo is not pending", () => {
+ expect(isCreateRepoPending({})).toEqual(false);
+ });
+
+ it("should return error when create repo did fail", () => {
+ const state = {
+ failure: {
+ [CREATE_REPO]: error
+ }
+ };
+ expect(getCreateRepoFailure(state)).toEqual(error);
+ });
+
+ it("should return undefined when create repo did not fail", () => {
+ expect(getCreateRepoFailure({})).toBe(undefined);
+ });
+
+ // modify
+
+ it("should return true, when modify repo is pending", () => {
+ const state = {
+ pending: {
+ [MODIFY_REPO + "/slarti/fjords"]: true
+ }
+ };
+
+ expect(isModifyRepoPending(state, "slarti", "fjords")).toEqual(true);
+ });
+
+ it("should return false, when modify repo is not pending", () => {
+ expect(isModifyRepoPending({}, "slarti", "fjords")).toEqual(false);
+ });
+
+ it("should return error, when modify repo failed", () => {
+ const state = {
+ failure: {
+ [MODIFY_REPO + "/slarti/fjords"]: error
+ }
+ };
+
+ expect(getModifyRepoFailure(state, "slarti", "fjords")).toEqual(error);
+ });
+
+ it("should return undefined, when modify did not fail", () => {
+ expect(getModifyRepoFailure({}, "slarti", "fjords")).toBeUndefined();
+ });
+
+ // delete
+
+ it("should return true, when delete repo is pending", () => {
+ const state = {
+ pending: {
+ [DELETE_REPO + "/slarti/fjords"]: true
+ }
+ };
+ expect(isDeleteRepoPending(state, "slarti", "fjords")).toEqual(true);
+ });
+
+ it("should return false, when delete repo is not pending", () => {
+ expect(isDeleteRepoPending({}, "slarti", "fjords")).toEqual(false);
+ });
+
+ it("should return error when delete repo did fail", () => {
+ const state = {
+ failure: {
+ [DELETE_REPO + "/slarti/fjords"]: error
+ }
+ };
+ expect(getDeleteRepoFailure(state, "slarti", "fjords")).toEqual(error);
+ });
+
+ it("should return undefined when delete repo did not fail", () => {
+ expect(getDeleteRepoFailure({}, "slarti", "fjords")).toBe(undefined);
+ });
+
+ it("should return true if the list contains the create link", () => {
+ const state = {
+ repos: {
+ list: repositoryCollection
+ }
+ };
+
+ expect(isAbleToCreateRepos(state)).toBe(true);
+ });
+
+ it("should return false, if create link is unavailable", () => {
+ const state = {
+ repos: {
+ list: {
+ _links: {}
+ }
+ }
+ };
+
+ expect(isAbleToCreateRepos(state)).toBe(false);
+ });
+});
diff --git a/scm-ui/src/repos/modules/repositoryTypes.js b/scm-ui/src/repos/modules/repositoryTypes.js
new file mode 100644
index 0000000000..c41fdcfc67
--- /dev/null
+++ b/scm-ui/src/repos/modules/repositoryTypes.js
@@ -0,0 +1,110 @@
+// @flow
+
+import * as types from "../../modules/types";
+import type { Action } from "../../types/Action";
+import type {
+ RepositoryType,
+ RepositoryTypeCollection
+} from "../types/RepositoryTypes";
+import { apiClient } from "../../apiclient";
+import { isPending } from "../../modules/pending";
+import { getFailure } from "../../modules/failure";
+
+export const FETCH_REPOSITORY_TYPES = "scm/repos/FETCH_REPOSITORY_TYPES";
+export const FETCH_REPOSITORY_TYPES_PENDING = `${FETCH_REPOSITORY_TYPES}_${
+ types.PENDING_SUFFIX
+}`;
+export const FETCH_REPOSITORY_TYPES_SUCCESS = `${FETCH_REPOSITORY_TYPES}_${
+ types.SUCCESS_SUFFIX
+}`;
+export const FETCH_REPOSITORY_TYPES_FAILURE = `${FETCH_REPOSITORY_TYPES}_${
+ types.FAILURE_SUFFIX
+}`;
+
+export function fetchRepositoryTypesIfNeeded() {
+ return function(dispatch: any, getState: () => Object) {
+ if (shouldFetchRepositoryTypes(getState())) {
+ return fetchRepositoryTypes(dispatch);
+ }
+ };
+}
+
+function fetchRepositoryTypes(dispatch: any) {
+ dispatch(fetchRepositoryTypesPending());
+ return apiClient
+ .get("repositoryTypes")
+ .then(response => response.json())
+ .then(repositoryTypes => {
+ dispatch(fetchRepositoryTypesSuccess(repositoryTypes));
+ })
+ .catch(err => {
+ const error = new Error(
+ `failed to fetch repository types: ${err.message}`
+ );
+ dispatch(fetchRepositoryTypesFailure(error));
+ });
+}
+
+export function shouldFetchRepositoryTypes(state: Object) {
+ if (
+ isFetchRepositoryTypesPending(state) ||
+ getFetchRepositoryTypesFailure(state)
+ ) {
+ return false;
+ }
+ if (state.repositoryTypes && state.repositoryTypes.length > 0) {
+ return false;
+ }
+ return true;
+}
+
+export function fetchRepositoryTypesPending(): Action {
+ return {
+ type: FETCH_REPOSITORY_TYPES_PENDING
+ };
+}
+
+export function fetchRepositoryTypesSuccess(
+ repositoryTypes: RepositoryTypeCollection
+): Action {
+ return {
+ type: FETCH_REPOSITORY_TYPES_SUCCESS,
+ payload: repositoryTypes
+ };
+}
+
+export function fetchRepositoryTypesFailure(error: Error): Action {
+ return {
+ type: FETCH_REPOSITORY_TYPES_FAILURE,
+ payload: error
+ };
+}
+
+// reducers
+
+export default function reducer(
+ state: RepositoryType[] = [],
+ action: Action = { type: "UNKNOWN" }
+): RepositoryType[] {
+ if (action.type === FETCH_REPOSITORY_TYPES_SUCCESS && action.payload) {
+ return action.payload._embedded["repositoryTypes"];
+ }
+ return state;
+}
+
+// selectors
+
+export function getRepositoryTypes(state: Object) {
+ if (state.repositoryTypes) {
+ return state.repositoryTypes;
+ }
+ return [];
+}
+
+export function isFetchRepositoryTypesPending(state: Object) {
+ return isPending(state, FETCH_REPOSITORY_TYPES);
+}
+
+export function getFetchRepositoryTypesFailure(state: Object) {
+ return getFailure(state, FETCH_REPOSITORY_TYPES);
+}
diff --git a/scm-ui/src/repos/modules/repositoryTypes.test.js b/scm-ui/src/repos/modules/repositoryTypes.test.js
new file mode 100644
index 0000000000..1f29bfa77f
--- /dev/null
+++ b/scm-ui/src/repos/modules/repositoryTypes.test.js
@@ -0,0 +1,198 @@
+// @flow
+
+import fetchMock from "fetch-mock";
+import configureMockStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import {
+ FETCH_REPOSITORY_TYPES,
+ FETCH_REPOSITORY_TYPES_FAILURE,
+ FETCH_REPOSITORY_TYPES_PENDING,
+ FETCH_REPOSITORY_TYPES_SUCCESS,
+ fetchRepositoryTypesIfNeeded,
+ fetchRepositoryTypesSuccess,
+ getFetchRepositoryTypesFailure,
+ getRepositoryTypes,
+ isFetchRepositoryTypesPending,
+ shouldFetchRepositoryTypes
+} from "./repositoryTypes";
+import reducer from "./repositoryTypes";
+
+const git = {
+ name: "git",
+ displayName: "Git",
+ _links: {
+ self: {
+ href: "http://localhost:8081/scm/api/rest/v2/repositoryTypes/git"
+ }
+ }
+};
+
+const hg = {
+ name: "hg",
+ displayName: "Mercurial",
+ _links: {
+ self: {
+ href: "http://localhost:8081/scm/api/rest/v2/repositoryTypes/hg"
+ }
+ }
+};
+
+const svn = {
+ name: "svn",
+ displayName: "Subversion",
+ _links: {
+ self: {
+ href: "http://localhost:8081/scm/api/rest/v2/repositoryTypes/svn"
+ }
+ }
+};
+
+const collection = {
+ _embedded: {
+ repositoryTypes: [git, hg, svn]
+ },
+ _links: {
+ self: {
+ href: "http://localhost:8081/scm/api/rest/v2/repositoryTypes"
+ }
+ }
+};
+
+describe("repository types caching", () => {
+ it("should fetch repository types, on empty state", () => {
+ expect(shouldFetchRepositoryTypes({})).toBe(true);
+ });
+
+ it("should fetch repository types, if the state contains an empty array", () => {
+ const state = {
+ repositoryTypes: []
+ };
+ expect(shouldFetchRepositoryTypes(state)).toBe(true);
+ });
+
+ it("should not fetch repository types, on pending state", () => {
+ const state = {
+ pending: {
+ [FETCH_REPOSITORY_TYPES]: true
+ }
+ };
+ expect(shouldFetchRepositoryTypes(state)).toBe(false);
+ });
+
+ it("should not fetch repository types, on failure state", () => {
+ const state = {
+ failure: {
+ [FETCH_REPOSITORY_TYPES]: new Error("no...")
+ }
+ };
+ expect(shouldFetchRepositoryTypes(state)).toBe(false);
+ });
+
+ it("should not fetch repository types, if they are already fetched", () => {
+ const state = {
+ repositoryTypes: [git, hg, svn]
+ };
+ expect(shouldFetchRepositoryTypes(state)).toBe(false);
+ });
+});
+
+describe("repository types fetch", () => {
+ const URL = "/scm/api/rest/v2/repositoryTypes";
+ const mockStore = configureMockStore([thunk]);
+
+ afterEach(() => {
+ fetchMock.reset();
+ fetchMock.restore();
+ });
+
+ it("should successfully fetch repository types", () => {
+ fetchMock.getOnce(URL, collection);
+
+ const expectedActions = [
+ { type: FETCH_REPOSITORY_TYPES_PENDING },
+ {
+ type: FETCH_REPOSITORY_TYPES_SUCCESS,
+ payload: collection
+ }
+ ];
+
+ const store = mockStore({});
+ return store.dispatch(fetchRepositoryTypesIfNeeded()).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should dispatch FETCH_REPOSITORY_TYPES_FAILURE on server error", () => {
+ fetchMock.getOnce(URL, {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(fetchRepositoryTypesIfNeeded()).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toBe(FETCH_REPOSITORY_TYPES_PENDING);
+ expect(actions[1].type).toBe(FETCH_REPOSITORY_TYPES_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should dispatch not dispatch any action, if the repository types are already fetched", () => {
+ const store = mockStore({
+ repositoryTypes: [git, hg, svn]
+ });
+ store.dispatch(fetchRepositoryTypesIfNeeded());
+ expect(store.getActions().length).toBe(0);
+ });
+});
+
+describe("repository types reducer", () => {
+ it("should return unmodified state on unknown action", () => {
+ const state = [];
+ expect(reducer(state)).toBe(state);
+ });
+ it("should store the repository types on FETCH_REPOSITORY_TYPES_SUCCESS", () => {
+ const newState = reducer([], fetchRepositoryTypesSuccess(collection));
+ expect(newState).toEqual([git, hg, svn]);
+ });
+});
+
+describe("repository types selectors", () => {
+ const error = new Error("The end of the universe");
+
+ it("should return an emtpy array", () => {
+ expect(getRepositoryTypes({})).toEqual([]);
+ });
+
+ it("should return the repository types", () => {
+ const state = {
+ repositoryTypes: [git, hg, svn]
+ };
+ expect(getRepositoryTypes(state)).toEqual([git, hg, svn]);
+ });
+
+ it("should return true, when fetch repository types is pending", () => {
+ const state = {
+ pending: {
+ [FETCH_REPOSITORY_TYPES]: true
+ }
+ };
+ expect(isFetchRepositoryTypesPending(state)).toEqual(true);
+ });
+
+ it("should return false, when fetch repos is not pending", () => {
+ expect(isFetchRepositoryTypesPending({})).toEqual(false);
+ });
+
+ it("should return error when fetch repository types did fail", () => {
+ const state = {
+ failure: {
+ [FETCH_REPOSITORY_TYPES]: error
+ }
+ };
+ expect(getFetchRepositoryTypesFailure(state)).toEqual(error);
+ });
+
+ it("should return undefined when fetch repos did not fail", () => {
+ expect(getFetchRepositoryTypesFailure({})).toBe(undefined);
+ });
+});
diff --git a/scm-ui/src/repos/types/Repositories.js b/scm-ui/src/repos/types/Repositories.js
new file mode 100644
index 0000000000..24eefd2cd7
--- /dev/null
+++ b/scm-ui/src/repos/types/Repositories.js
@@ -0,0 +1,25 @@
+//@flow
+import type { Links } from "../../types/hal";
+import type { PagedCollection } from "../../types/Collection";
+
+export type Repository = {
+ namespace: string,
+ name: string,
+ type: string,
+ contact?: string,
+ description?: string,
+ creationDate?: string,
+ lastModified?: string,
+ _links: Links
+};
+
+export type RepositoryCollection = PagedCollection & {
+ _embedded: {
+ repositories: Repository[] | string[]
+ }
+};
+
+export type RepositoryGroup = {
+ name: string,
+ repositories: Repository[]
+};
diff --git a/scm-ui/src/repos/types/RepositoryTypes.js b/scm-ui/src/repos/types/RepositoryTypes.js
new file mode 100644
index 0000000000..c9e77185e5
--- /dev/null
+++ b/scm-ui/src/repos/types/RepositoryTypes.js
@@ -0,0 +1,14 @@
+// @flow
+
+import type { Collection } from "../../types/Collection";
+
+export type RepositoryType = {
+ name: string,
+ displayName: string
+};
+
+export type RepositoryTypeCollection = Collection & {
+ _embedded: {
+ "repositoryTypes": RepositoryType[]
+ }
+};
diff --git a/scm-ui/src/repositories/containers/Repositories.js b/scm-ui/src/repositories/containers/Repositories.js
deleted file mode 100644
index 162c6cfc4d..0000000000
--- a/scm-ui/src/repositories/containers/Repositories.js
+++ /dev/null
@@ -1,24 +0,0 @@
-// @flow
-import React from "react";
-import { Page } from "../../components/layout";
-import { translate } from "react-i18next";
-
-type Props = {
- t: string => string
-};
-
-class Repositories extends React.Component {
- render() {
- const { t } = this.props;
- return (
-
- {t("repositories.body")}
-
- );
- }
-}
-
-export default translate("repositories")(Repositories);
diff --git a/scm-ui/src/users/components/UserForm.js b/scm-ui/src/users/components/UserForm.js
index 39189ca127..7a44eda272 100644
--- a/scm-ui/src/users/components/UserForm.js
+++ b/scm-ui/src/users/components/UserForm.js
@@ -4,7 +4,8 @@ import { translate } from "react-i18next";
import type { User } from "../types/User";
import { Checkbox, InputField } from "../../components/forms";
import { SubmitButton } from "../../components/buttons";
-import * as validator from "./userValidation";
+import * as validator from "../../components/validation";
+import * as userValidator from "./userValidation";
type Props = {
submitForm: User => void,
@@ -157,7 +158,9 @@ class UserForm extends React.Component {
handleDisplayNameChange = (displayName: string) => {
this.setState({
- displayNameValidationError: !validator.isDisplayNameValid(displayName),
+ displayNameValidationError: !userValidator.isDisplayNameValid(
+ displayName
+ ),
user: { ...this.state.user, displayName }
});
};
@@ -175,7 +178,7 @@ class UserForm extends React.Component {
this.state.validatePassword
);
this.setState({
- validatePasswordError: !validator.isPasswordValid(password),
+ validatePasswordError: !userValidator.isPasswordValid(password),
passwordValidationError: validatePasswordError,
user: { ...this.state.user, password }
});
diff --git a/scm-ui/src/users/components/buttons/CreateUserButton.js b/scm-ui/src/users/components/buttons/CreateUserButton.js
index 91d4292509..000f7ca315 100644
--- a/scm-ui/src/users/components/buttons/CreateUserButton.js
+++ b/scm-ui/src/users/components/buttons/CreateUserButton.js
@@ -1,30 +1,20 @@
//@flow
import React from "react";
-import injectSheet from "react-jss";
import { translate } from "react-i18next";
-import { AddButton } from "../../../components/buttons";
-import classNames from "classnames";
-
-const styles = {
- spacing: {
- margin: "1em 0 0 1em"
- }
-};
+import { CreateButton } from "../../../components/buttons";
+// TODO remove
type Props = {
- t: string => string,
- classes: any
+ t: string => string
};
class CreateUserButton extends React.Component {
render() {
- const { classes, t } = this.props;
+ const { t } = this.props;
return (
-
+
);
}
}
-export default translate("users")(injectSheet(styles)(CreateUserButton));
+export default translate("users")(CreateUserButton);
diff --git a/scm-ui/src/users/components/table/Details.js b/scm-ui/src/users/components/table/Details.js
index 269df9db10..c2b90b0f2e 100644
--- a/scm-ui/src/users/components/table/Details.js
+++ b/scm-ui/src/users/components/table/Details.js
@@ -3,6 +3,8 @@ import React from "react";
import type { User } from "../../types/User";
import { translate } from "react-i18next";
import { Checkbox } from "../../../components/forms";
+import MailLink from "../../../components/MailLink";
+import DateFromNow from "../../../components/DateFromNow";
type Props = {
user: User,
@@ -25,7 +27,9 @@ class Details extends React.Component {
| {t("user.mail")} |
- {user.mail} |
+
+
+ |
| {t("user.admin")} |
@@ -39,6 +43,22 @@ class Details extends React.Component {
+
+ | {t("user.type")} |
+ {user.type} |
+
+
+ | {t("user.creationDate")} |
+
+
+ |
+
+
+ | {t("user.lastModified")} |
+
+
+ |
+
);
diff --git a/scm-ui/src/users/components/userValidation.js b/scm-ui/src/users/components/userValidation.js
index a5d592e4fb..77b6c08c16 100644
--- a/scm-ui/src/users/components/userValidation.js
+++ b/scm-ui/src/users/components/userValidation.js
@@ -1,24 +1,11 @@
// @flow
-const nameRegex = /^([A-z0-9.\-_@]|[^ ]([A-z0-9.\-_@ ]*[A-z0-9.\-_@]|[^\s])?)$/;
-
-export const isNameValid = (name: string) => {
- return nameRegex.test(name);
-};
-
export const isDisplayNameValid = (displayName: string) => {
if (displayName) {
return true;
}
return false;
};
-
-const mailRegex = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-.]*\.[A-z0-9][A-z0-9-]+$/;
-
-export const isMailValid = (mail: string) => {
- return mailRegex.test(mail);
-};
-
export const isPasswordValid = (password: string) => {
return password.length > 6 && password.length < 32;
};
diff --git a/scm-ui/src/users/components/userValidation.test.js b/scm-ui/src/users/components/userValidation.test.js
index 370247fe90..9e09cd725c 100644
--- a/scm-ui/src/users/components/userValidation.test.js
+++ b/scm-ui/src/users/components/userValidation.test.js
@@ -1,45 +1,6 @@
// @flow
import * as validator from "./userValidation";
-describe("test name validation", () => {
- it("should return false", () => {
- // invalid names taken from ValidationUtilTest.java
- const invalidNames = [
- " test 123",
- " test 123 ",
- "test 123 ",
- "test/123",
- "test%123",
- "test:123",
- "t ",
- " t",
- " t ",
- ""
- ];
- for (let name of invalidNames) {
- expect(validator.isNameValid(name)).toBe(false);
- }
- });
-
- it("should return true", () => {
- // valid names taken from ValidationUtilTest.java
- const validNames = [
- "test",
- "test.git",
- "Test123.git",
- "Test123-git",
- "Test_user-123.git",
- "test@scm-manager.de",
- "test 123",
- "tt",
- "t"
- ];
- for (let name of validNames) {
- expect(validator.isNameValid(name)).toBe(true);
- }
- });
-});
-
describe("test displayName validation", () => {
it("should return false", () => {
expect(validator.isDisplayNameValid("")).toBe(false);
@@ -60,41 +21,6 @@ describe("test displayName validation", () => {
});
});
-describe("test mail validation", () => {
- it("should return false", () => {
- // invalid taken from ValidationUtilTest.java
- const invalid = [
- "ostfalia.de",
- "@ostfalia.de",
- "s.sdorra@",
- "s.sdorra@ostfalia",
- "s.sdorra@@ostfalia.de",
- "s.sdorra@ ostfalia.de",
- "s.sdorra @ostfalia.de"
- ];
- for (let mail of invalid) {
- expect(validator.isMailValid(mail)).toBe(false);
- }
- });
-
- it("should return true", () => {
- // valid taken from ValidationUtilTest.java
- const valid = [
- "s.sdorra@ostfalia.de",
- "sdorra@ostfalia.de",
- "s.sdorra@hbk-bs.de",
- "s.sdorra@gmail.com",
- "s.sdorra@t.co",
- "s.sdorra@ucla.college",
- "s.sdorra@example.xn--p1ai",
- "s.sdorra@scm.solutions"
- ];
- for (let mail of valid) {
- expect(validator.isMailValid(mail)).toBe(true);
- }
- });
-});
-
describe("test password validation", () => {
it("should return false", () => {
// invalid taken from ValidationUtilTest.java
diff --git a/scm-ui/src/users/containers/AddUser.js b/scm-ui/src/users/containers/AddUser.js
index a690c147e3..fe114d0cae 100644
--- a/scm-ui/src/users/containers/AddUser.js
+++ b/scm-ui/src/users/containers/AddUser.js
@@ -48,6 +48,7 @@ class AddUser extends React.Component {
title={t("add-user.title")}
subtitle={t("add-user.subtitle")}
error={error}
+ showContentOnError={true}
>
this.createUser(user)}
diff --git a/scm-ui/src/users/containers/Users.js b/scm-ui/src/users/containers/Users.js
index c9120ba8ec..60f104a42d 100644
--- a/scm-ui/src/users/containers/Users.js
+++ b/scm-ui/src/users/containers/Users.js
@@ -50,9 +50,9 @@ class Users extends React.Component {
/**
* reflect page transitions in the uri
*/
- componentDidUpdate = (prevProps: Props) => {
+ componentDidUpdate() {
const { page, list } = this.props;
- if (list.page) {
+ if (list && (list.page || list.page === 0)) {
// backend starts paging by 0
const statePage: number = list.page + 1;
if (page !== statePage) {
diff --git a/scm-ui/src/users/modules/users.js b/scm-ui/src/users/modules/users.js
index afd77021af..767c27119b 100644
--- a/scm-ui/src/users/modules/users.js
+++ b/scm-ui/src/users/modules/users.js
@@ -143,7 +143,7 @@ export function createUser(user: User, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(createUserPending(user));
return apiClient
- .postWithContentType(USERS_URL, user, CONTENT_TYPE_USER)
+ .post(USERS_URL, user, CONTENT_TYPE_USER)
.then(() => {
dispatch(createUserSuccess());
if (callback) {
@@ -192,7 +192,7 @@ export function modifyUser(user: User, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(modifyUserPending(user));
return apiClient
- .putWithContentType(user._links.update.href, user, CONTENT_TYPE_USER)
+ .put(user._links.update.href, user, CONTENT_TYPE_USER)
.then(() => {
dispatch(modifyUserSuccess(user));
if (callback) {
diff --git a/scm-ui/src/users/types/User.js b/scm-ui/src/users/types/User.js
index b113f7f795..58be91e0ba 100644
--- a/scm-ui/src/users/types/User.js
+++ b/scm-ui/src/users/types/User.js
@@ -8,5 +8,8 @@ export type User = {
password: string,
admin: boolean,
active: boolean,
+ type?: string,
+ creationDate?: string,
+ lastModified?: string,
_links: Links
};
diff --git a/scm-ui/yarn.lock b/scm-ui/yarn.lock
index 5d252931e5..08df201c59 100644
--- a/scm-ui/yarn.lock
+++ b/scm-ui/yarn.lock
@@ -3104,6 +3104,10 @@ follow-redirects@^1.0.0:
dependencies:
debug "^3.1.0"
+font-awesome@^4.7.0:
+ version "4.7.0"
+ resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133"
+
for-in@^1.0.1, for-in@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -5216,6 +5220,10 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi
dependencies:
minimist "0.0.8"
+moment@^2.22.2:
+ version "2.22.2"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
+
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
diff --git a/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java b/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java
index b94ce934f6..abc9ab2fd9 100644
--- a/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java
+++ b/scm-webapp/src/main/java/sonia/scm/ManagerDaoAdapter.java
@@ -4,6 +4,7 @@ import com.github.sdorra.ssp.PermissionCheck;
import sonia.scm.util.AssertUtil;
import java.util.function.Function;
+import java.util.function.Predicate;
import java.util.function.Supplier;
public class ManagerDaoAdapter {
@@ -38,9 +39,13 @@ public class ManagerDaoAdapter {
}
public T create(T newObject, Supplier permissionCheck, AroundHandler beforeCreate, AroundHandler afterCreate) throws E {
+ return create(newObject, permissionCheck, beforeCreate, afterCreate, dao::contains);
+ }
+
+ public T create(T newObject, Supplier permissionCheck, AroundHandler beforeCreate, AroundHandler afterCreate, Predicate existsCheck) throws E {
permissionCheck.get().check();
AssertUtil.assertIsValid(newObject);
- if (dao.contains(newObject)) {
+ if (existsCheck.test(newObject)) {
throw alreadyExistsException.apply(newObject);
}
newObject.setCreationDate(System.currentTimeMillis());
diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractManagerResource.java b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractManagerResource.java
index 27ceb03a36..2ad7a64255 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractManagerResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/rest/resources/AbstractManagerResource.java
@@ -374,20 +374,6 @@ public abstract class AbstractManagerResource {
+
+ private final String collectionName;
+ private final BaseMapper mapper;
+
+ protected CollectionToDtoMapper(String collectionName, BaseMapper mapper) {
+ this.collectionName = collectionName;
+ this.mapper = mapper;
+ }
+
+ public HalRepresentation map(Collection collection) {
+ List dtos = collection.stream().map(mapper::map).collect(Collectors.toList());
+ return new HalRepresentation(
+ linkingTo().self(createSelfLink()).build(),
+ embeddedBuilder().with(collectionName, dtos).build()
+ );
+ }
+
+ protected abstract String createSelfLink();
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigResource.java
index 755e0c1c32..bf3a11fb9c 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigResource.java
@@ -16,6 +16,9 @@ import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Response;
+/**
+ * RESTful Web Service Resource to manage the configuration.
+ */
@Path(ConfigResource.CONFIG_PATH_V2)
public class ConfigResource {
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java
index 0f68894a64..4a9c797b9f 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java
@@ -42,7 +42,7 @@ public class GroupCollectionResource {
@GET
@Path("")
@Produces(VndMediaType.GROUP_COLLECTION)
- @TypeHint(GroupDto[].class)
+ @TypeHint(CollectionDto.class)
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 400, condition = "\"sortBy\" field unknown"),
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java
index 69430bf43b..3ee508db89 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java
@@ -21,6 +21,9 @@ public class MapperModule extends AbstractModule {
bind(RepositoryToRepositoryDtoMapper.class).to(Mappers.getMapper(RepositoryToRepositoryDtoMapper.class).getClass());
bind(RepositoryDtoToRepositoryMapper.class).to(Mappers.getMapper(RepositoryDtoToRepositoryMapper.class).getClass());
+ bind(RepositoryTypeToRepositoryTypeDtoMapper.class).to(Mappers.getMapper(RepositoryTypeToRepositoryTypeDtoMapper.class).getClass());
+ bind(RepositoryTypeCollectionToDtoMapper.class);
+
bind(UriInfoStore.class).in(ServletScopes.REQUEST);
}
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java
index 50025d3cdb..f9a32c7ddd 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeResource.java
@@ -19,6 +19,9 @@ import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
+/**
+ * RESTful Web Service Resource to get currently logged in users.
+ */
@Path(MeResource.ME_PATH_V2)
public class MeResource {
static final String ME_PATH_V2 = "v2/me/";
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java
index 7a70957b91..c6d54e7f4e 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryCollectionResource.java
@@ -40,7 +40,7 @@ public class RepositoryCollectionResource {
@GET
@Path("")
@Produces(VndMediaType.REPOSITORY_COLLECTION)
- @TypeHint(RepositoryDto[].class)
+ @TypeHint(CollectionDto.class)
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java
index 34896149c3..2c227f78f2 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java
@@ -3,14 +3,22 @@ package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint;
+import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryException;
+import sonia.scm.repository.RepositoryIsNotArchivedException;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.inject.Provider;
-import javax.ws.rs.*;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
import javax.ws.rs.core.Response;
import java.util.Optional;
import java.util.function.Predicate;
@@ -40,7 +48,7 @@ public class RepositoryResource {
this.dtoToRepositoryMapper = dtoToRepositoryMapper;
this.manager = manager;
this.repositoryToDtoMapper = repositoryToDtoMapper;
- this.adapter = new SingleResourceManagerAdapter<>(manager, Repository.class);
+ this.adapter = new SingleResourceManagerAdapter<>(manager, Repository.class, this::handleNotArchived);
this.tagRootResource = tagRootResource;
this.branchRootResource = branchRootResource;
this.changesetRootResource = changesetRootResource;
@@ -148,8 +156,16 @@ public class RepositoryResource {
return permissionRootResource.get();
}
+ private Optional handleNotArchived(Throwable throwable) {
+ if (throwable instanceof RepositoryIsNotArchivedException) {
+ return Optional.of(Response.status(Response.Status.PRECONDITION_FAILED).build());
+ } else {
+ return Optional.empty();
+ }
+ }
+
private Supplier> loadBy(String namespace, String name) {
- return () -> manager.getByNamespace(namespace, name);
+ return () -> Optional.ofNullable(manager.get(new NamespaceAndName(namespace, name)));
}
private Predicate nameAndNamespaceStaysTheSame(String namespace, String name) {
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java
index 2f13723d39..cb89f0ea63 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java
@@ -25,6 +25,7 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper {
+
+ private final ResourceLinks resourceLinks;
+
+ @Inject
+ public RepositoryTypeCollectionToDtoMapper(RepositoryTypeToRepositoryTypeDtoMapper mapper, ResourceLinks resourceLinks) {
+ super("repositoryTypes", mapper);
+ this.resourceLinks = resourceLinks;
+ }
+
+ @Override
+ protected String createSelfLink() {
+ return resourceLinks.repositoryTypeCollection().self();
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeDto.java
new file mode 100644
index 0000000000..73a1b60b9e
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeDto.java
@@ -0,0 +1,22 @@
+package sonia.scm.api.v2.resources;
+
+import de.otto.edison.hal.HalRepresentation;
+import de.otto.edison.hal.Links;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@NoArgsConstructor
+@Getter
+@Setter
+public class RepositoryTypeDto extends HalRepresentation {
+
+ private String name;
+ private String displayName;
+
+ @Override
+ @SuppressWarnings("squid:S1185") // We want to have this method available in this package
+ protected HalRepresentation add(Links links) {
+ return super.add(links);
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeResource.java
new file mode 100644
index 0000000000..3a47fdf6c6
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeResource.java
@@ -0,0 +1,53 @@
+package sonia.scm.api.v2.resources;
+
+import com.webcohesion.enunciate.metadata.rs.ResponseCode;
+import com.webcohesion.enunciate.metadata.rs.StatusCodes;
+import com.webcohesion.enunciate.metadata.rs.TypeHint;
+import sonia.scm.repository.RepositoryManager;
+import sonia.scm.repository.RepositoryType;
+import sonia.scm.web.VndMediaType;
+
+import javax.inject.Inject;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Response;
+
+public class RepositoryTypeResource {
+
+ private RepositoryManager repositoryManager;
+ private RepositoryTypeToRepositoryTypeDtoMapper mapper;
+
+ @Inject
+ public RepositoryTypeResource(RepositoryManager repositoryManager, RepositoryTypeToRepositoryTypeDtoMapper mapper) {
+ this.repositoryManager = repositoryManager;
+ this.mapper = mapper;
+ }
+
+ /**
+ * Returns the specified repository type.
+ *
+ * Note: This method requires "group" privilege.
+ *
+ * @param name of the requested repository type
+ */
+ @GET
+ @Path("")
+ @Produces(VndMediaType.REPOSITORY_TYPE)
+ @TypeHint(RepositoryTypeDto.class)
+ @StatusCodes({
+ @ResponseCode(code = 200, condition = "success"),
+ @ResponseCode(code = 404, condition = "not found, no repository type with the specified name available"),
+ @ResponseCode(code = 500, condition = "internal server error")
+ })
+ public Response get(@PathParam("name") String name) {
+ for (RepositoryType type : repositoryManager.getConfiguredTypes()) {
+ if (name.equalsIgnoreCase(type.getName())) {
+ return Response.ok(mapper.map(type)).build();
+ }
+ }
+ return Response.status(Response.Status.NOT_FOUND).build();
+ }
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeRootResource.java
new file mode 100644
index 0000000000..5b5998a0a9
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeRootResource.java
@@ -0,0 +1,35 @@
+package sonia.scm.api.v2.resources;
+
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.ws.rs.Path;
+
+/**
+ * RESTful Web Service Resource to get available repository types.
+ */
+@Path(RepositoryTypeRootResource.PATH)
+public class RepositoryTypeRootResource {
+
+ static final String PATH = "v2/repositoryTypes/";
+
+ private Provider collectionResourceProvider;
+ private Provider resourceProvider;
+
+ @Inject
+ public RepositoryTypeRootResource(Provider collectionResourceProvider, Provider resourceProvider) {
+ this.collectionResourceProvider = collectionResourceProvider;
+ this.resourceProvider = resourceProvider;
+ }
+
+ @Path("")
+ public RepositoryTypeCollectionResource getRepositoryTypeCollectionResource() {
+ return collectionResourceProvider.get();
+ }
+
+ @Path("{name}")
+ public RepositoryTypeResource getRepositoryTypeResource() {
+ return resourceProvider.get();
+ }
+
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java
new file mode 100644
index 0000000000..438273a514
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java
@@ -0,0 +1,25 @@
+package sonia.scm.api.v2.resources;
+
+import de.otto.edison.hal.Links;
+import org.mapstruct.AfterMapping;
+import org.mapstruct.Mapper;
+import org.mapstruct.MappingTarget;
+import sonia.scm.repository.RepositoryType;
+
+import javax.inject.Inject;
+
+import static de.otto.edison.hal.Links.linkingTo;
+
+@Mapper
+public abstract class RepositoryTypeToRepositoryTypeDtoMapper extends BaseMapper {
+
+ @Inject
+ private ResourceLinks resourceLinks;
+
+ @AfterMapping
+ void appendLinks(RepositoryType repositoryType, @MappingTarget RepositoryTypeDto target) {
+ Links.Builder linksBuilder = linkingTo().self(resourceLinks.repositoryType().self(repositoryType.getName()));
+ target.add(linksBuilder.build());
+ }
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
index d02747d2af..0b89f0de3f 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
@@ -2,6 +2,7 @@ package sonia.scm.api.v2.resources;
import javax.inject.Inject;
import javax.ws.rs.core.UriInfo;
+import java.net.URI;
class ResourceLinks {
@@ -127,15 +128,21 @@ class ResourceLinks {
static class RepositoryLinks {
private final LinkBuilder repositoryLinkBuilder;
+ private final UriInfo uriInfo;
RepositoryLinks(UriInfo uriInfo) {
repositoryLinkBuilder = new LinkBuilder(uriInfo, RepositoryRootResource.class, RepositoryResource.class);
+ this.uriInfo = uriInfo;
}
String self(String namespace, String name) {
return repositoryLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("get").parameters().href();
}
+ String clone(String type, String namespace, String name) {
+ return uriInfo.getBaseUri().resolve(URI.create("../../" + type + "/" + namespace + "/" + name)).toASCIIString();
+ }
+
String delete(String namespace, String name) {
return repositoryLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("delete").parameters().href();
}
@@ -165,6 +172,39 @@ class ResourceLinks {
}
}
+ public RepositoryTypeLinks repositoryType() {
+ return new RepositoryTypeLinks(uriInfoStore.get());
+ }
+
+ static class RepositoryTypeLinks {
+ private final LinkBuilder repositoryTypeLinkBuilder;
+
+ RepositoryTypeLinks(UriInfo uriInfo) {
+ repositoryTypeLinkBuilder = new LinkBuilder(uriInfo, RepositoryTypeRootResource.class, RepositoryTypeResource.class);
+ }
+
+ String self(String name) {
+ return repositoryTypeLinkBuilder.method("getRepositoryTypeResource").parameters(name).method("get").parameters().href();
+ }
+ }
+
+ public RepositoryTypeCollectionLinks repositoryTypeCollection() {
+ return new RepositoryTypeCollectionLinks(uriInfoStore.get());
+ }
+
+ static class RepositoryTypeCollectionLinks {
+ private final LinkBuilder collectionLinkBuilder;
+
+ RepositoryTypeCollectionLinks(UriInfo uriInfo) {
+ collectionLinkBuilder = new LinkBuilder(uriInfo, RepositoryTypeRootResource.class, RepositoryTypeCollectionResource.class);
+ }
+
+ String self() {
+ return collectionLinkBuilder.method("getRepositoryTypeCollectionResource").parameters().method("getAll").parameters().href();
+ }
+ }
+
+
public TagCollectionLinks tagCollection() {
return new TagCollectionLinks(uriInfoStore.get());
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java
index 7f8b115dee..06195284df 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SingleResourceManagerAdapter.java
@@ -31,8 +31,15 @@ class SingleResourceManagerAdapter extends AbstractManagerResource {
+ private final Function> errorHandler;
+
SingleResourceManagerAdapter(Manager manager, Class type) {
+ this(manager, type, e -> Optional.empty());
+ }
+
+ SingleResourceManagerAdapter(Manager manager, Class type, Function> errorHandler) {
super(manager, type);
+ this.errorHandler = errorHandler;
}
/**
@@ -70,6 +77,11 @@ class SingleResourceManagerAdapter> createGenericEntity(Collection modelObjects) {
throw new UnsupportedOperationException();
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java
index ef826980b3..36a1e69a83 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java
@@ -42,7 +42,7 @@ public class UserCollectionResource {
@GET
@Path("")
@Produces(VndMediaType.USER_COLLECTION)
- @TypeHint(UserDto[].class)
+ @TypeHint(CollectionDto.class)
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 400, condition = "\"sortBy\" field unknown"),
diff --git a/scm-webapp/src/main/java/sonia/scm/debug/DebugHook.java b/scm-webapp/src/main/java/sonia/scm/debug/DebugHook.java
index d75b455a95..7fe813159a 100644
--- a/scm-webapp/src/main/java/sonia/scm/debug/DebugHook.java
+++ b/scm-webapp/src/main/java/sonia/scm/debug/DebugHook.java
@@ -34,13 +34,14 @@ import com.github.legman.ReferenceType;
import com.github.legman.Subscribe;
import com.google.common.base.Function;
import com.google.common.collect.Collections2;
-import javax.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.EagerSingleton;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.PostReceiveRepositoryHookEvent;
+import javax.inject.Inject;
+
/**
* {@link PostReceiveRepositoryHookEvent} which stores receives data and passes it to the {@link DebugService}.
*
@@ -78,7 +79,7 @@ public final class DebugHook
LOG.trace("store changeset ids from repository", event.getRepository().getId());
debugService.put(
- event.getRepository().getId(),
+ event.getRepository().getNamespaceAndName(),
new DebugHookData(Collections2.transform(
event.getContext().getChangesetProvider().getChangesetList(), IDEXTRACTOR)
));
diff --git a/scm-webapp/src/main/java/sonia/scm/debug/DebugResource.java b/scm-webapp/src/main/java/sonia/scm/debug/DebugResource.java
index 0933242b49..6ee035bf01 100644
--- a/scm-webapp/src/main/java/sonia/scm/debug/DebugResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/debug/DebugResource.java
@@ -30,20 +30,22 @@
*/
package sonia.scm.debug;
-import java.util.Collection;
+import sonia.scm.repository.NamespaceAndName;
+
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
+import java.util.Collection;
/**
* Rest api resource for the {@link DebugService}.
*
* @author Sebastian Sdorra
*/
-@Path("debug/{repository}/post-receive")
+@Path("debug/{namespace}/{name}/post-receive")
public final class DebugResource
{
private final DebugService debugService;
@@ -62,28 +64,30 @@ public final class DebugResource
/**
* Returns all received hook data for the given repository.
*
- * @param repository repository id
- *
+ * @param namespace repository namespace
+ * @param name repository name
+ *
* @return all received hook data for the given repository
*/
@GET
@Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
- public Collection getAll(@PathParam("repository") String repository){
- return debugService.getAll(repository);
+ public Collection getAll(@PathParam("namespace") String namespace, @PathParam("name") String name){
+ return debugService.getAll(new NamespaceAndName(namespace, name));
}
/**
* Returns the last received hook data for the given repository.
- *
- * @param repository repository id
+ *
+ * @param namespace repository namespace
+ * @param name repository name
*
* @return the last received hook data for the given repository
*/
@GET
@Path("last")
@Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
- public DebugHookData getLast(@PathParam("repository") String repository){
- return debugService.getLast(repository);
+ public DebugHookData getLast(@PathParam("namespace") String namespace, @PathParam("name") String name){
+ return debugService.getLast(new NamespaceAndName(namespace, name));
}
}
diff --git a/scm-webapp/src/main/java/sonia/scm/debug/DebugService.java b/scm-webapp/src/main/java/sonia/scm/debug/DebugService.java
index 31282b6b08..8e2475d802 100644
--- a/scm-webapp/src/main/java/sonia/scm/debug/DebugService.java
+++ b/scm-webapp/src/main/java/sonia/scm/debug/DebugService.java
@@ -34,10 +34,12 @@ import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.Multimap;
import com.google.inject.Singleton;
-import java.util.Collection;
import org.apache.shiro.SecurityUtils;
+import sonia.scm.repository.NamespaceAndName;
import sonia.scm.security.Role;
+import java.util.Collection;
+
/**
* The DebugService stores and returns received data from repository hook events.
*
@@ -47,30 +49,23 @@ import sonia.scm.security.Role;
public final class DebugService
{
- private final Multimap receivedHooks = LinkedListMultimap.create();
+ private final Multimap receivedHooks = LinkedListMultimap.create();
/**
* Stores {@link DebugHookData} for the given repository.
- *
- * @param repository repository id
- * @param hookData received hook data
*/
- void put(String repository, DebugHookData hookData)
+ void put(NamespaceAndName namespaceAndName, DebugHookData hookData)
{
- receivedHooks.put(repository, hookData);
+ receivedHooks.put(namespaceAndName, hookData);
}
/**
* Returns the last received hook data for the given repository.
- *
- * @param repository repository id
- *
- * @return the last received hook data for the given repository
*/
- public DebugHookData getLast(String repository){
+ public DebugHookData getLast(NamespaceAndName namespaceAndName){
SecurityUtils.getSubject().checkRole(Role.ADMIN);
DebugHookData hookData = null;
- Collection receivedHookData = receivedHooks.get(repository);
+ Collection receivedHookData = receivedHooks.get(namespaceAndName);
if (receivedHookData != null && ! receivedHookData.isEmpty()){
hookData = Iterables.getLast(receivedHookData);
}
@@ -79,14 +74,9 @@ public final class DebugService
/**
* Returns all received hook data for the given repository.
- *
- * @param repository repository id
- *
- * @return all received hook data for the given repository
*/
- public Collection getAll(String repository){
+ public Collection getAll(NamespaceAndName namespaceAndName){
SecurityUtils.getSubject().checkRole(Role.ADMIN);
- return receivedHooks.get(repository);
+ return receivedHooks.get(namespaceAndName);
}
-
}
diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceStrategy.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceStrategy.java
index 9cf2c8ab20..35a5abea24 100644
--- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceStrategy.java
+++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultNamespaceStrategy.java
@@ -1,17 +1,24 @@
package sonia.scm.repository;
+import com.google.common.base.Strings;
import org.apache.shiro.SecurityUtils;
import sonia.scm.plugin.Extension;
/**
- * The DefaultNamespaceStrategy returns the username of the currently logged in user as namespace.
+ * The DefaultNamespaceStrategy returns the predefined namespace of the given repository, if the namespace was not set
+ * the username of the currently loggedin user is used.
+ *
* @since 2.0.0
*/
@Extension
public class DefaultNamespaceStrategy implements NamespaceStrategy {
@Override
- public String getNamespace() {
- return SecurityUtils.getSubject().getPrincipal().toString();
+ public String createNamespace(Repository repository) {
+ String namespace = repository.getNamespace();
+ if (Strings.isNullOrEmpty(namespace)) {
+ namespace = SecurityUtils.getSubject().getPrincipal().toString();
+ }
+ return namespace;
}
}
diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java
index 6998893652..2eb9889434 100644
--- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java
+++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java
@@ -42,14 +42,30 @@ import com.google.inject.Singleton;
import org.apache.shiro.concurrent.SubjectAwareExecutorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import sonia.scm.*;
+import sonia.scm.ArgumentIsInvalidException;
+import sonia.scm.ConfigurationException;
+import sonia.scm.HandlerEventType;
+import sonia.scm.ManagerDaoAdapter;
+import sonia.scm.SCMContextProvider;
+import sonia.scm.Type;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.KeyGenerator;
-import sonia.scm.util.*;
+import sonia.scm.util.AssertUtil;
+import sonia.scm.util.CollectionAppender;
+import sonia.scm.util.HttpUtil;
+import sonia.scm.util.IOUtil;
+import sonia.scm.util.Util;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
-import java.util.*;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
@@ -125,7 +141,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
public Repository create(Repository repository, boolean initRepository) throws RepositoryException {
repository.setId(keyGenerator.createKey());
- repository.setNamespace(namespaceStrategy.getNamespace());
+ repository.setNamespace(namespaceStrategy.createNamespace(repository));
logger.info("create repository {} of type {} in namespace {}", repository.getName(), repository.getType(), repository.getNamespace());
@@ -138,7 +154,8 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
}
fireEvent(HandlerEventType.BEFORE_CREATE, newRepository);
},
- newRepository -> fireEvent(HandlerEventType.CREATE, newRepository)
+ newRepository -> fireEvent(HandlerEventType.CREATE, newRepository),
+ newRepository -> repositoryDAO.contains(newRepository.getNamespaceAndName())
);
}
@@ -155,7 +172,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
private void preDelete(Repository toDelete) throws RepositoryException {
if (configuration.isEnableRepositoryArchive() && !toDelete.isArchived()) {
- throw new RepositoryIsNotArchivedException("Repository could not deleted, because it is not archived.");
+ throw new RepositoryIsNotArchivedException();
}
fireEvent(HandlerEventType.BEFORE_DELETE, toDelete);
getHandler(toDelete).delete(toDelete);
@@ -284,8 +301,8 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
}
@Override
- public Collection getConfiguredTypes() {
- List validTypes = Lists.newArrayList();
+ public Collection getConfiguredTypes() {
+ List validTypes = Lists.newArrayList();
for (RepositoryHandler handler : handlerMap.values()) {
if (handler.isConfigured()) {
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java
index a8616fe2ce..888d4d6f25 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java
@@ -13,8 +13,10 @@ import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import sonia.scm.PageResult;
+import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryException;
+import sonia.scm.repository.RepositoryIsNotArchivedException;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.web.VndMediaType;
@@ -25,16 +27,20 @@ import java.net.URISyntaxException;
import java.net.URL;
import static java.util.Collections.singletonList;
-import static java.util.Optional.empty;
-import static java.util.Optional.of;
-import static javax.servlet.http.HttpServletResponse.*;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyObject;
-import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.eq;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;
@SubjectAware(
@@ -72,7 +78,7 @@ public class RepositoryRootResourceTest {
@Test
public void shouldFailForNotExistingRepository() throws URISyntaxException {
- when(repositoryManager.getByNamespace(anyString(), anyString())).thenReturn(empty());
+ when(repositoryManager.get(any(NamespaceAndName.class))).thenReturn(null);
mockRepository("space", "repo");
MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/other");
@@ -127,7 +133,7 @@ public class RepositoryRootResourceTest {
public void shouldHandleUpdateForNotExistingRepository() throws URISyntaxException, IOException {
URL url = Resources.getResource("sonia/scm/api/v2/repository-test-update.json");
byte[] repository = Resources.toByteArray(url);
- when(repositoryManager.getByNamespace(anyString(), anyString())).thenReturn(empty());
+ when(repositoryManager.get(any(NamespaceAndName.class))).thenReturn(null);
MockHttpRequest request = MockHttpRequest
.put("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo")
@@ -191,6 +197,20 @@ public class RepositoryRootResourceTest {
verify(repositoryManager).delete(anyObject());
}
+ @Test
+ public void shouldHandleDeleteIsNotArchivedException() throws Exception {
+ mockRepository("space", "repo");
+
+ doThrow(RepositoryIsNotArchivedException.class).when(repositoryManager).delete(anyObject());
+
+ MockHttpRequest request = MockHttpRequest.delete("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo");
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertEquals(SC_PRECONDITION_FAILED, response.getStatus());
+ }
+
@Test
public void shouldCreateNewRepositoryInCorrectNamespace() throws URISyntaxException, IOException, RepositoryException {
when(repositoryManager.create(any())).thenAnswer(invocation -> {
@@ -225,7 +245,7 @@ public class RepositoryRootResourceTest {
repository.setName(name);
String id = namespace + "-" + name;
repository.setId(id);
- when(repositoryManager.getByNamespace(namespace, name)).thenReturn(of(repository));
+ when(repositoryManager.get(new NamespaceAndName(namespace, name))).thenReturn(repository);
when(repositoryManager.get(id)).thenReturn(repository);
return repository;
}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionToDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionToDtoMapperTest.java
new file mode 100644
index 0000000000..dbd46ec5f9
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionToDtoMapperTest.java
@@ -0,0 +1,60 @@
+package sonia.scm.api.v2.resources;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import de.otto.edison.hal.Embedded;
+import de.otto.edison.hal.HalRepresentation;
+import de.otto.edison.hal.Link;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.runners.MockitoJUnitRunner;
+import sonia.scm.repository.RepositoryType;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.Assert.*;
+
+@RunWith(MockitoJUnitRunner.class)
+public class RepositoryTypeCollectionToDtoMapperTest {
+
+ private final URI baseUri = URI.create("https://scm-manager.org/scm/");
+
+ @SuppressWarnings("unused") // Is injected
+ private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
+
+ @InjectMocks
+ private RepositoryTypeToRepositoryTypeDtoMapperImpl mapper;
+
+ private List types = Lists.newArrayList(
+ new RepositoryType("hk", "Hitchhiker", Sets.newHashSet()),
+ new RepositoryType("hog", "Heart of Gold", Sets.newHashSet())
+ );
+
+ private RepositoryTypeCollectionToDtoMapper collectionMapper;
+
+ @Before
+ public void setUpEnvironment() {
+ collectionMapper = new RepositoryTypeCollectionToDtoMapper(mapper, resourceLinks);
+ }
+
+ @Test
+ public void shouldHaveEmbeddedDtos() {
+ HalRepresentation mappedTypes = collectionMapper.map(types);
+ Embedded embedded = mappedTypes.getEmbedded();
+ List embeddedTypes = embedded.getItemsBy("repositoryTypes", RepositoryTypeDto.class);
+ assertEquals("hk", embeddedTypes.get(0).getName());
+ assertEquals("hog", embeddedTypes.get(1).getName());
+ }
+
+ @Test
+ public void shouldHaveSelfLink() {
+ HalRepresentation mappedTypes = collectionMapper.map(types);
+ Optional self = mappedTypes.getLinks().getLinkBy("self");
+ assertEquals("https://scm-manager.org/scm/v2/repositoryTypes/", self.get().getHref());
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeRootResourceTest.java
new file mode 100644
index 0000000000..0dfb6bf92c
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeRootResourceTest.java
@@ -0,0 +1,142 @@
+package sonia.scm.api.v2.resources;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import org.jboss.resteasy.core.Dispatcher;
+import org.jboss.resteasy.mock.MockDispatcherFactory;
+import org.jboss.resteasy.mock.MockHttpRequest;
+import org.jboss.resteasy.mock.MockHttpResponse;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.runners.MockitoJUnitRunner;
+import sonia.scm.repository.RepositoryManager;
+import sonia.scm.repository.RepositoryType;
+import sonia.scm.web.VndMediaType;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.List;
+
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+import static org.junit.Assert.*;
+import static org.hamcrest.Matchers.*;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class RepositoryTypeRootResourceTest {
+
+ private final Dispatcher dispatcher = MockDispatcherFactory.createDispatcher();
+
+ @Mock
+ private RepositoryManager repositoryManager;
+
+ private final URI baseUri = URI.create("/");
+ private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
+
+ @InjectMocks
+ private RepositoryTypeToRepositoryTypeDtoMapperImpl mapper;
+
+ private List types = Lists.newArrayList(
+ new RepositoryType("hk", "Hitchhiker", Sets.newHashSet()),
+ new RepositoryType("hog", "Heart of Gold", Sets.newHashSet())
+ );
+
+ @Before
+ public void prepareEnvironment() {
+ when(repositoryManager.getConfiguredTypes()).thenReturn(types);
+
+ RepositoryTypeCollectionToDtoMapper collectionMapper = new RepositoryTypeCollectionToDtoMapper(mapper, resourceLinks);
+ RepositoryTypeCollectionResource collectionResource = new RepositoryTypeCollectionResource(repositoryManager, collectionMapper);
+ RepositoryTypeResource resource = new RepositoryTypeResource(repositoryManager, mapper);
+ RepositoryTypeRootResource rootResource = new RepositoryTypeRootResource(MockProvider.of(collectionResource), MockProvider.of(resource));
+ dispatcher.getRegistry().addSingletonResource(rootResource);
+ }
+
+ @Test
+ public void shouldHaveCollectionVndMediaType() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertEquals(SC_OK, response.getStatus());
+ String contentType = response.getOutputHeaders().getFirst("Content-Type").toString();
+ assertThat(VndMediaType.REPOSITORY_TYPE_COLLECTION, equalToIgnoringCase(contentType));
+ }
+
+ @Test
+ public void shouldHaveCollectionSelfLink() throws URISyntaxException {
+ String uri = "/" + RepositoryTypeRootResource.PATH;
+ MockHttpRequest request = MockHttpRequest.get(uri);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertEquals(SC_OK, response.getStatus());
+ assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"" + uri + "\"}"));
+ }
+
+ @Test
+ public void shouldHaveEmbeddedRepositoryTypes() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertEquals(SC_OK, response.getStatus());
+ assertTrue(response.getContentAsString().contains("Hitchhiker"));
+ assertTrue(response.getContentAsString().contains("Heart of Gold"));
+ }
+
+ @Test
+ public void shouldHaveVndMediaType() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH + "hk");
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertEquals(SC_OK, response.getStatus());
+ String contentType = response.getOutputHeaders().getFirst("Content-Type").toString();
+ assertThat(VndMediaType.REPOSITORY_TYPE, equalToIgnoringCase(contentType));
+ }
+
+ @Test
+ public void shouldContainAttributes() throws URISyntaxException {
+ String uri = "/" + RepositoryTypeRootResource.PATH + "hk";
+ MockHttpRequest request = MockHttpRequest.get(uri);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertEquals(SC_OK, response.getStatus());
+ assertTrue(response.getContentAsString().contains("hk"));
+ assertTrue(response.getContentAsString().contains("Hitchhiker"));
+ }
+
+ @Test
+ public void shouldHaveSelfLink() throws URISyntaxException {
+ String uri = "/" + RepositoryTypeRootResource.PATH + "hk";
+ MockHttpRequest request = MockHttpRequest.get(uri);
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertEquals(SC_OK, response.getStatus());
+ assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"" + uri + "\"}"));
+ }
+
+ @Test
+ public void shouldReturn404OnUnknownTypes() throws URISyntaxException {
+ MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH + "git");
+ MockHttpResponse response = new MockHttpResponse();
+
+ dispatcher.invoke(request, response);
+
+ assertEquals(SC_NOT_FOUND, response.getStatus());
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java
new file mode 100644
index 0000000000..73f0a5672c
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java
@@ -0,0 +1,42 @@
+package sonia.scm.api.v2.resources;
+
+import com.google.common.collect.Sets;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.runners.MockitoJUnitRunner;
+import sonia.scm.repository.RepositoryType;
+
+import java.net.URI;
+
+import static org.junit.Assert.assertEquals;
+
+@RunWith(MockitoJUnitRunner.class)
+public class RepositoryTypeToRepositoryTypeDtoMapperTest {
+
+ private final URI baseUri = URI.create("https://scm-manager.org/scm/");
+
+ @SuppressWarnings("unused") // Is injected
+ private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
+
+ @InjectMocks
+ private RepositoryTypeToRepositoryTypeDtoMapperImpl mapper;
+
+ private RepositoryType type = new RepositoryType("hk", "Hitchhiker", Sets.newHashSet());
+
+ @Test
+ public void shouldMapSimpleProperties() {
+ RepositoryTypeDto dto = mapper.map(type);
+ assertEquals("hk", dto.getName());
+ assertEquals("Hitchhiker", dto.getDisplayName());
+ }
+
+ @Test
+ public void shouldAppendSelfLink() {
+ RepositoryTypeDto dto = mapper.map(type);
+ assertEquals(
+ "https://scm-manager.org/scm/v2/repositoryTypes/hk",
+ dto.getLinks().getLinkBy("self").get().getHref()
+ );
+ }
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java
index b5fe5c4da4..18cda7deed 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java
@@ -25,6 +25,9 @@ public class ResourceLinksMock {
when(resourceLinks.sourceCollection()).thenReturn(new ResourceLinks.SourceCollectionLinks(uriInfo));
when(resourceLinks.permissionCollection()).thenReturn(new ResourceLinks.PermissionCollectionLinks(uriInfo));
when(resourceLinks.config()).thenReturn(new ResourceLinks.ConfigLinks(uriInfo));
+ when(resourceLinks.repositoryType()).thenReturn(new ResourceLinks.RepositoryTypeLinks(uriInfo));
+ when(resourceLinks.repositoryTypeCollection()).thenReturn(new ResourceLinks.RepositoryTypeCollectionLinks(uriInfo));
+
return resourceLinks;
}
}
diff --git a/scm-webapp/src/test/java/sonia/scm/it/AbstractAdminITCaseBase.java b/scm-webapp/src/test/java/sonia/scm/it/AbstractAdminITCaseBase.java
index f8ec4d2d10..45a24585e3 100644
--- a/scm-webapp/src/test/java/sonia/scm/it/AbstractAdminITCaseBase.java
+++ b/scm-webapp/src/test/java/sonia/scm/it/AbstractAdminITCaseBase.java
@@ -35,45 +35,22 @@ package sonia.scm.it;
//~--- non-JDK imports --------------------------------------------------------
-import org.junit.After;
-import org.junit.Before;
-
-import static sonia.scm.it.IntegrationTestUtil.*;
+import static sonia.scm.it.IntegrationTestUtil.createAdminClient;
//~--- JDK imports ------------------------------------------------------------
-import com.sun.jersey.api.client.Client;
-
/**
*
* @author Sebastian Sdorra
*/
public class AbstractAdminITCaseBase
{
-
- /**
- * Method description
- *
- */
- @Before
- public void login()
- {
- client = createClient();
- authenticateAdmin(client);
- }
-
- /**
- * Method description
- *
- */
- @After
- public void logout()
- {
- logoutClient(client);
+ public AbstractAdminITCaseBase() {
+ client = createAdminClient();
}
//~--- fields ---------------------------------------------------------------
/** Field description */
- protected Client client;
+ protected final ScmClient client;
}
diff --git a/scm-webapp/src/test/java/sonia/scm/it/AbstractPermissionITCaseBase.java b/scm-webapp/src/test/java/sonia/scm/it/AbstractPermissionITCaseBase.java
index cd655c78b5..d72f952876 100644
--- a/scm-webapp/src/test/java/sonia/scm/it/AbstractPermissionITCaseBase.java
+++ b/scm-webapp/src/test/java/sonia/scm/it/AbstractPermissionITCaseBase.java
@@ -35,29 +35,26 @@ package sonia.scm.it;
//~--- non-JDK imports --------------------------------------------------------
-import org.junit.After;
+import com.sun.jersey.api.client.ClientResponse;
+import com.sun.jersey.api.client.WebResource;
import org.junit.AfterClass;
-import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runners.Parameterized.Parameters;
-
import sonia.scm.user.User;
import sonia.scm.user.UserTestData;
-import static org.junit.Assert.*;
+import java.util.Collection;
-import static sonia.scm.it.IntegrationTestUtil.*;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static sonia.scm.it.IntegrationTestUtil.createAdminClient;
+import static sonia.scm.it.IntegrationTestUtil.createResource;
+import static sonia.scm.it.IntegrationTestUtil.post;
//~--- JDK imports ------------------------------------------------------------
-import com.sun.jersey.api.client.Client;
-import com.sun.jersey.api.client.ClientResponse;
-import com.sun.jersey.api.client.WebResource;
-
-import java.util.ArrayList;
-import java.util.Collection;
-
/**
*
* @author Sebastian Sdorra
@@ -77,26 +74,24 @@ public abstract class AbstractPermissionITCaseBase
public AbstractPermissionITCaseBase(Credentials credentials)
{
this.credentials = credentials;
+ this.client = credentials.isAnonymous()? ScmClient.anonymous(): new ScmClient(credentials.getUsername(), credentials.getPassword());
}
- //~--- methods --------------------------------------------------------------
+ //~--- methods --------------------------------------------------------------
/**
* Method description
*
*
* @return
*/
- @Parameters
- public static Collection createParameters()
+ @Parameters(name = "{1}")
+ public static Collection