+
-
+
+
+
+
+
{
path="/user/:name"
component={SingleUser}
/>
+
+
+
+
);
diff --git a/scm-ui/src/createReduxStore.js b/scm-ui/src/createReduxStore.js
index dd16f9cbfc..55e0d3f514 100644
--- a/scm-ui/src/createReduxStore.js
+++ b/scm-ui/src/createReduxStore.js
@@ -5,6 +5,9 @@ import { createStore, compose, applyMiddleware, combineReducers } from "redux";
import { routerReducer, routerMiddleware } from "react-router-redux";
import users from "./users/modules/users";
+import repos from "./repos/modules/repos";
+import repositoryTypes from "./repos/modules/repositoryTypes";
+import groups from "./groups/modules/groups";
import auth from "./modules/auth";
import pending from "./modules/pending";
import failure from "./modules/failure";
@@ -20,6 +23,9 @@ function createReduxStore(history: BrowserHistory) {
pending,
failure,
users,
+ repos,
+ repositoryTypes,
+ groups,
auth
});
diff --git a/scm-ui/src/groups/components/AddMemberField.js b/scm-ui/src/groups/components/AddMemberField.js
new file mode 100644
index 0000000000..6237e88291
--- /dev/null
+++ b/scm-ui/src/groups/components/AddMemberField.js
@@ -0,0 +1,71 @@
+//@flow
+import React from "react";
+
+import { translate } from "react-i18next";
+import { AddButton } from "../../components/buttons";
+import InputField from "../../components/forms/InputField";
+import { isMemberNameValid } from "./groupValidation";
+
+type Props = {
+ t: string => string,
+ addMember: string => void
+};
+
+type State = {
+ memberToAdd: string,
+ validationError: boolean
+};
+
+class AddMemberField extends React.Component
{
+ constructor(props) {
+ super(props);
+ this.state = {
+ memberToAdd: "",
+ validationError: false
+ };
+ }
+
+ render() {
+ const { t } = this.props;
+ return (
+
+ );
+ }
+
+ addButtonClicked = (event: Event) => {
+ event.preventDefault();
+ this.appendMember();
+ };
+
+ appendMember = () => {
+ const { memberToAdd } = this.state;
+ if (isMemberNameValid(memberToAdd)) {
+ this.props.addMember(memberToAdd);
+ this.setState({ ...this.state, memberToAdd: "" });
+ }
+ };
+
+ handleAddMemberChange = (membername: string) => {
+ this.setState({
+ ...this.state,
+ memberToAdd: membername,
+ validationError: membername.length > 0 && !isMemberNameValid(membername)
+ });
+ };
+}
+
+export default translate("groups")(AddMemberField);
diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js
new file mode 100644
index 0000000000..2e941ea7eb
--- /dev/null
+++ b/scm-ui/src/groups/components/GroupForm.js
@@ -0,0 +1,151 @@
+//@flow
+import React from "react";
+
+import InputField from "../../components/forms/InputField";
+import { SubmitButton } from "../../components/buttons";
+import { translate } from "react-i18next";
+import type { Group } from "../types/Group";
+import * as validator from "./groupValidation";
+import AddMemberField from "./AddMemberField";
+import MemberNameTable from "./MemberNameTable";
+import Textarea from "../../components/forms/Textarea";
+
+type Props = {
+ t: string => string,
+ submitForm: Group => void,
+ loading?: boolean,
+ group?: Group
+};
+
+type State = {
+ group: Group,
+ nameValidationError: boolean
+};
+
+class GroupForm extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ group: {
+ name: "",
+ description: "",
+ _embedded: {
+ members: []
+ },
+ _links: {},
+ members: [],
+ type: ""
+ },
+ nameValidationError: false
+ };
+ }
+
+ componentDidMount() {
+ const { group } = this.props;
+ if (group) {
+ this.setState({ ...this.state, group: { ...group } });
+ }
+ }
+
+ isFalsy(value) {
+ if (!value) {
+ return true;
+ }
+ return false;
+ }
+
+ isValid = () => {
+ const group = this.state.group;
+ return !(this.state.nameValidationError || this.isFalsy(group.name));
+ };
+
+ submit = (event: Event) => {
+ event.preventDefault();
+ if (this.isValid()) {
+ this.props.submitForm(this.state.group);
+ }
+ };
+
+ render() {
+ const { t, loading } = this.props;
+ const group = this.state.group;
+ let nameField = null;
+ if (!this.props.group) {
+ nameField = (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ memberListChanged = membernames => {
+ this.setState({
+ ...this.state,
+ group: {
+ ...this.state.group,
+ members: membernames
+ }
+ });
+ };
+
+ addMember = (membername: string) => {
+ if (this.isMember(membername)) {
+ return;
+ }
+
+ this.setState({
+ ...this.state,
+ group: {
+ ...this.state.group,
+ members: [...this.state.group.members, membername]
+ }
+ });
+ };
+
+ isMember = (membername: string) => {
+ return this.state.group.members.includes(membername);
+ };
+
+ handleGroupNameChange = (name: string) => {
+ this.setState({
+ nameValidationError: !validator.isNameValid(name),
+ group: { ...this.state.group, name }
+ });
+ };
+
+ handleDescriptionChange = (description: string) => {
+ this.setState({
+ group: { ...this.state.group, description }
+ });
+ };
+}
+
+export default translate("groups")(GroupForm);
diff --git a/scm-ui/src/groups/components/MemberNameTable.js b/scm-ui/src/groups/components/MemberNameTable.js
new file mode 100644
index 0000000000..699c009413
--- /dev/null
+++ b/scm-ui/src/groups/components/MemberNameTable.js
@@ -0,0 +1,47 @@
+//@flow
+import React from "react";
+import { translate } from "react-i18next";
+import RemoveMemberButton from "./buttons/RemoveMemberButton";
+
+type Props = {
+ members: string[],
+ t: string => string,
+ memberListChanged: (string[]) => void
+};
+
+type State = {};
+
+class MemberNameTable extends React.Component {
+ render() {
+ const { t } = this.props;
+ return (
+
+
+
+
+ {this.props.members.map(member => {
+ return (
+
+ | {member} |
+
+
+ |
+
+ );
+ })}
+
+
+
+ );
+ }
+
+ removeMember = (membername: string) => {
+ const newMembers = this.props.members.filter(name => name !== membername);
+ this.props.memberListChanged(newMembers);
+ };
+}
+
+export default translate("groups")(MemberNameTable);
diff --git a/scm-ui/src/groups/components/buttons/CreateGroupButton.js b/scm-ui/src/groups/components/buttons/CreateGroupButton.js
new file mode 100644
index 0000000000..423ad71f4c
--- /dev/null
+++ b/scm-ui/src/groups/components/buttons/CreateGroupButton.js
@@ -0,0 +1,19 @@
+//@flow
+import React from "react";
+import { translate } from "react-i18next";
+import { CreateButton } from "../../../components/buttons";
+
+type Props = {
+ t: string => string
+};
+
+class CreateGroupButton extends React.Component {
+ render() {
+ const { t } = this.props;
+ return (
+
+ );
+ }
+}
+
+export default translate("groups")(CreateGroupButton);
diff --git a/scm-ui/src/groups/components/buttons/RemoveMemberButton.js b/scm-ui/src/groups/components/buttons/RemoveMemberButton.js
new file mode 100644
index 0000000000..40c7b39cc0
--- /dev/null
+++ b/scm-ui/src/groups/components/buttons/RemoveMemberButton.js
@@ -0,0 +1,34 @@
+//@flow
+import React from "react";
+import { DeleteButton } from "../../../components/buttons";
+import { translate } from "react-i18next";
+import classNames from "classnames";
+
+type Props = {
+ t: string => string,
+ membername: string,
+ removeMember: string => void
+};
+
+type State = {};
+
+
+
+class RemoveMemberButton extends React.Component {
+ render() {
+ const { t , membername, removeMember} = this.props;
+ return (
+
+ {
+ event.preventDefault();
+ removeMember(membername);
+ }}
+ />
+
+ );
+ }
+}
+
+export default translate("groups")(RemoveMemberButton);
diff --git a/scm-ui/src/groups/components/groupValidation.js b/scm-ui/src/groups/components/groupValidation.js
new file mode 100644
index 0000000000..bb76f86024
--- /dev/null
+++ b/scm-ui/src/groups/components/groupValidation.js
@@ -0,0 +1,8 @@
+// @flow
+import { isNameValid } from "../../components/validation";
+
+export { isNameValid };
+
+export const isMemberNameValid = (name: string) => {
+ return isNameValid(name);
+};
diff --git a/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js b/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js
new file mode 100644
index 0000000000..a54865e1e7
--- /dev/null
+++ b/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js
@@ -0,0 +1,57 @@
+// @flow
+import React from "react";
+import { translate } from "react-i18next";
+import type { Group } from "../../types/Group";
+import { confirmAlert } from "../../../components/modals/ConfirmAlert";
+import { NavAction } from "../../../components/navigation";
+
+type Props = {
+ group: Group,
+ confirmDialog?: boolean,
+ t: string => string,
+ deleteGroup: (group: Group) => void
+};
+
+export class DeleteGroupNavLink extends React.Component {
+ static defaultProps = {
+ confirmDialog: true
+ };
+
+ deleteGroup = () => {
+ this.props.deleteGroup(this.props.group);
+ };
+
+ confirmDelete = () => {
+ const { t } = this.props;
+ confirmAlert({
+ title: t("delete-group-button.confirm-alert.title"),
+ message: t("delete-group-button.confirm-alert.message"),
+ buttons: [
+ {
+ label: t("delete-group-button.confirm-alert.submit"),
+ onClick: () => this.deleteGroup()
+ },
+ {
+ label: t("delete-group-button.confirm-alert.cancel"),
+ onClick: () => null
+ }
+ ]
+ });
+ };
+
+ isDeletable = () => {
+ return this.props.group._links.delete;
+ };
+
+ render() {
+ const { confirmDialog, t } = this.props;
+ const action = confirmDialog ? this.confirmDelete : this.deleteGroup;
+
+ if (!this.isDeletable()) {
+ return null;
+ }
+ return ;
+ }
+}
+
+export default translate("groups")(DeleteGroupNavLink);
diff --git a/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.test.js b/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.test.js
new file mode 100644
index 0000000000..eec9e164ca
--- /dev/null
+++ b/scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.test.js
@@ -0,0 +1,79 @@
+import React from "react";
+import { mount, shallow } from "enzyme";
+import "../../../tests/enzyme";
+import "../../../tests/i18n";
+import DeleteGroupNavLink from "./DeleteGroupNavLink";
+
+import { confirmAlert } from "../../../components/modals/ConfirmAlert";
+jest.mock("../../../components/modals/ConfirmAlert");
+
+describe("DeleteGroupNavLink", () => {
+ it("should render nothing, if the delete link is missing", () => {
+ const group = {
+ _links: {}
+ };
+
+ const navLink = shallow(
+ {}} />
+ );
+ expect(navLink.text()).toBe("");
+ });
+
+ it("should render the navLink", () => {
+ const group = {
+ _links: {
+ delete: {
+ href: "/groups"
+ }
+ }
+ };
+
+ const navLink = mount(
+ {}} />
+ );
+ expect(navLink.text()).not.toBe("");
+ });
+
+ it("should open the confirm dialog on navLink click", () => {
+ const group = {
+ _links: {
+ delete: {
+ href: "/groups"
+ }
+ }
+ };
+
+ const navLink = mount(
+ {}} />
+ );
+ navLink.find("a").simulate("click");
+
+ expect(confirmAlert.mock.calls.length).toBe(1);
+ });
+
+ it("should call the delete group function with delete url", () => {
+ const group = {
+ _links: {
+ delete: {
+ href: "/groups"
+ }
+ }
+ };
+
+ let calledUrl = null;
+ function capture(group) {
+ calledUrl = group._links.delete.href;
+ }
+
+ const navLink = mount(
+
+ );
+ navLink.find("a").simulate("click");
+
+ expect(calledUrl).toBe("/groups");
+ });
+});
diff --git a/scm-ui/src/groups/components/navLinks/EditGroupNavLink.js b/scm-ui/src/groups/components/navLinks/EditGroupNavLink.js
new file mode 100644
index 0000000000..1cba7c761f
--- /dev/null
+++ b/scm-ui/src/groups/components/navLinks/EditGroupNavLink.js
@@ -0,0 +1,31 @@
+//@flow
+import React from 'react';
+import NavLink from "../../../components/navigation/NavLink";
+import { translate } from "react-i18next";
+import type { Group } from "../../types/Group";
+
+type Props = {
+ t: string => string,
+ editUrl: string,
+ group: Group
+}
+
+type State = {
+}
+
+class EditGroupNavLink extends React.Component {
+
+ render() {
+ const { t, editUrl } = this.props;
+ if (!this.isEditable()) {
+ return null;
+ }
+ return ;
+ }
+
+ isEditable = () => {
+ return this.props.group._links.update;
+ }
+}
+
+export default translate("groups")(EditGroupNavLink);
\ No newline at end of file
diff --git a/scm-ui/src/groups/components/navLinks/editGroupNavLink.test.js b/scm-ui/src/groups/components/navLinks/editGroupNavLink.test.js
new file mode 100644
index 0000000000..7399f4f714
--- /dev/null
+++ b/scm-ui/src/groups/components/navLinks/editGroupNavLink.test.js
@@ -0,0 +1,29 @@
+//@flow
+
+import React from "react";
+import { shallow } from "enzyme";
+import "../../../tests/enzyme";
+import "../../../tests/i18n";
+import EditGroupNavLink from "./EditGroupNavLink";
+
+it("should render nothing, if the edit link is missing", () => {
+ const group = {
+ _links: {}
+ };
+
+ const navLink = shallow();
+ expect(navLink.text()).toBe("");
+});
+
+it("should render the navLink", () => {
+ const group = {
+ _links: {
+ update: {
+ href: "/groups"
+ }
+ }
+ };
+
+ const navLink = shallow();
+ expect(navLink.text()).not.toBe("");
+});
diff --git a/scm-ui/src/groups/components/navLinks/index.js b/scm-ui/src/groups/components/navLinks/index.js
new file mode 100644
index 0000000000..30fdd34b6d
--- /dev/null
+++ b/scm-ui/src/groups/components/navLinks/index.js
@@ -0,0 +1,2 @@
+export { default as DeleteGroupNavLink } from "./DeleteGroupNavLink";
+export { default as EditGroupNavLink } from "./EditGroupNavLink";
diff --git a/scm-ui/src/groups/components/table/Details.js b/scm-ui/src/groups/components/table/Details.js
new file mode 100644
index 0000000000..5c9f1aa2da
--- /dev/null
+++ b/scm-ui/src/groups/components/table/Details.js
@@ -0,0 +1,69 @@
+//@flow
+import React from "react";
+import type { Group } from "../../types/Group";
+import { translate } from "react-i18next";
+import GroupMember from "./GroupMember";
+import DateFromNow from "../../../components/DateFromNow";
+
+type Props = {
+ group: Group,
+ t: string => string
+};
+
+class Details extends React.Component {
+ render() {
+ const { group, t } = this.props;
+ return (
+
+
+
+ | {t("group.name")} |
+ {group.name} |
+
+
+ | {t("group.description")} |
+ {group.description} |
+
+
+ | {t("group.type")} |
+ {group.type} |
+
+
+ | {t("group.creationDate")} |
+
+
+ |
+
+
+ | {t("group.lastModified")} |
+
+
+ |
+
+ {this.renderMembers()}
+
+
+ );
+ }
+
+ renderMembers() {
+ if (this.props.group.members.length > 0) {
+ return (
+
+
+ {this.props.t("group.members")}
+
+ {this.props.group._embedded.members.map((member, index) => {
+ return ;
+ })}
+
+ |
+
+ );
+ } else {
+ return;
+ }
+ }
+}
+
+export default translate("groups")(Details);
diff --git a/scm-ui/src/groups/components/table/GroupMember.js b/scm-ui/src/groups/components/table/GroupMember.js
new file mode 100644
index 0000000000..3777b27660
--- /dev/null
+++ b/scm-ui/src/groups/components/table/GroupMember.js
@@ -0,0 +1,28 @@
+// @flow
+import React from "react";
+import { Link } from "react-router-dom";
+import type { Member } from "../../types/Group";
+
+type Props = {
+ member: Member
+};
+
+export default class GroupMember extends React.Component {
+ renderLink(to: string, label: string) {
+ return {label};
+ }
+
+ showName(to: any, member: Member) {
+ if (member._links.self) {
+ return this.renderLink(to, member.name);
+ } else {
+ return member.name;
+ }
+ }
+
+ render() {
+ const { member } = this.props;
+ const to = `/user/${member.name}`;
+ return {this.showName(to, member)};
+ }
+}
diff --git a/scm-ui/src/groups/components/table/GroupRow.js b/scm-ui/src/groups/components/table/GroupRow.js
new file mode 100644
index 0000000000..ba13588b32
--- /dev/null
+++ b/scm-ui/src/groups/components/table/GroupRow.js
@@ -0,0 +1,25 @@
+// @flow
+import React from "react";
+import { Link } from "react-router-dom";
+import type { Group } from "../../types/Group";
+
+type Props = {
+ group: Group
+};
+
+export default class GroupRow extends React.Component {
+ renderLink(to: string, label: string) {
+ return {label};
+ }
+
+ render() {
+ const { group } = this.props;
+ const to = `/group/${group.name}`;
+ return (
+
+ | {this.renderLink(to, group.name)} |
+ {group.description} |
+
+ );
+ }
+}
diff --git a/scm-ui/src/groups/components/table/GroupTable.js b/scm-ui/src/groups/components/table/GroupTable.js
new file mode 100644
index 0000000000..4f08c55016
--- /dev/null
+++ b/scm-ui/src/groups/components/table/GroupTable.js
@@ -0,0 +1,33 @@
+// @flow
+import React from "react";
+import { translate } from "react-i18next";
+import GroupRow from "./GroupRow";
+import type { Group } from "../../types/Group";
+
+type Props = {
+ t: string => string,
+ groups: Group[]
+};
+
+class GroupTable extends React.Component {
+ render() {
+ const { groups, t } = this.props;
+ return (
+
+
+
+ | {t("group.name")} |
+ {t("group.description")} |
+
+
+
+ {groups.map((group, index) => {
+ return ;
+ })}
+
+
+ );
+ }
+}
+
+export default translate("groups")(GroupTable);
diff --git a/scm-ui/src/groups/components/table/index.js b/scm-ui/src/groups/components/table/index.js
new file mode 100644
index 0000000000..e82be3f5ee
--- /dev/null
+++ b/scm-ui/src/groups/components/table/index.js
@@ -0,0 +1,3 @@
+export { default as Details } from "./Details";
+export { default as GroupRow } from "./GroupRow";
+export { default as GroupTable } from "./GroupTable";
diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js
new file mode 100644
index 0000000000..c3f33279ba
--- /dev/null
+++ b/scm-ui/src/groups/containers/AddGroup.js
@@ -0,0 +1,69 @@
+//@flow
+import React from "react";
+
+import Page from "../../components/layout/Page";
+import { translate } from "react-i18next";
+import GroupForm from "../components/GroupForm";
+import { connect } from "react-redux";
+import { createGroup, isCreateGroupPending, getCreateGroupFailure, createGroupReset } from "../modules/groups";
+import type { Group } from "../types/Group";
+import type { History } from "history";
+
+type Props = {
+ t: string => string,
+ createGroup: (group: Group, callback?: () => void) => void,
+ history: History,
+ loading?: boolean,
+ error?: Error,
+ resetForm: () => void,
+};
+
+type State = {};
+
+class AddGroup extends React.Component {
+
+ componentDidMount() {
+ this.props.resetForm();
+ }
+ render() {
+ const { t, loading, error } = this.props;
+ return (
+
+
+ this.createGroup(group)} loading={loading}/>
+
+
+ );
+ }
+
+ groupCreated = () => {
+ this.props.history.push("/groups");
+ };
+ createGroup = (group: Group) => {
+ this.props.createGroup(group, this.groupCreated);
+ };
+}
+
+const mapDispatchToProps = dispatch => {
+ return {
+ createGroup: (group: Group, callback?: () => void) =>
+ dispatch(createGroup(group, callback)),
+ resetForm: () => {
+ dispatch(createGroupReset());
+ }
+ };
+};
+
+const mapStateToProps = state => {
+ const loading = isCreateGroupPending(state);
+ const error = getCreateGroupFailure(state);
+ return {
+ loading,
+ error
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(translate("groups")(AddGroup));
diff --git a/scm-ui/src/groups/containers/EditGroup.js b/scm-ui/src/groups/containers/EditGroup.js
new file mode 100644
index 0000000000..63cdafa912
--- /dev/null
+++ b/scm-ui/src/groups/containers/EditGroup.js
@@ -0,0 +1,71 @@
+//@flow
+import React from "react";
+import { connect } from "react-redux";
+import GroupForm from "../components/GroupForm";
+import { modifyGroup, fetchGroup } from "../modules/groups";
+import type { History } from "history";
+import { withRouter } from "react-router-dom";
+import type { Group } from "../types/Group";
+import { isModifyGroupPending, getModifyGroupFailure } from "../modules/groups";
+import ErrorNotification from "../../components/ErrorNotification";
+
+type Props = {
+ group: Group,
+ modifyGroup: (group: Group, callback?: () => void) => void,
+ fetchGroup: (name: string) => void,
+ history: History,
+ loading?: boolean,
+ error: Error
+};
+
+class EditGroup extends React.Component {
+ groupModified = (group: Group) => () => {
+ this.props.fetchGroup(group.name);
+ this.props.history.push(`/group/${group.name}`);
+ };
+
+ modifyGroup = (group: Group) => {
+ this.props.modifyGroup(group, this.groupModified(group));
+ };
+
+ render() {
+ const { group, loading, error } = this.props;
+ return (
+
+
+ {
+ this.modifyGroup(group);
+ }}
+ loading={loading}
+ />
+
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const loading = isModifyGroupPending(state, ownProps.group.name);
+ const error = getModifyGroupFailure(state, ownProps.group.name);
+ return {
+ loading,
+ error
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ modifyGroup: (group: Group, callback?: () => void) => {
+ dispatch(modifyGroup(group, callback));
+ },
+ fetchGroup: (name: string) => {
+ dispatch(fetchGroup(name));
+ }
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(withRouter(EditGroup));
diff --git a/scm-ui/src/groups/containers/Groups.js b/scm-ui/src/groups/containers/Groups.js
new file mode 100644
index 0000000000..f7a4215f84
--- /dev/null
+++ b/scm-ui/src/groups/containers/Groups.js
@@ -0,0 +1,139 @@
+//@flow
+import React from "react";
+import { connect } from "react-redux";
+import { translate } from "react-i18next";
+import type { Group } from "../types/Group.js";
+import type { PagedCollection } from "../../types/Collection";
+import type { History } from "history";
+import { Page } from "../../components/layout";
+import { GroupTable } from "./../components/table";
+import Paginator from "../../components/Paginator";
+import CreateGroupButton from "../components/buttons/CreateGroupButton";
+
+import {
+ fetchGroupsByPage,
+ fetchGroupsByLink,
+ getGroupsFromState,
+ isFetchGroupsPending,
+ getFetchGroupsFailure,
+ isPermittedToCreateGroups,
+ selectListAsCollection
+} from "../modules/groups";
+
+type Props = {
+ groups: Group[],
+ loading: boolean,
+ error: Error,
+ canAddGroups: boolean,
+ list: PagedCollection,
+ page: number,
+
+ // context objects
+ t: string => string,
+ history: History,
+
+ // dispatch functions
+ fetchGroupsByPage: (page: number) => void,
+ fetchGroupsByLink: (link: string) => void
+};
+
+class Groups extends React.Component {
+ componentDidMount() {
+ this.props.fetchGroupsByPage(this.props.page);
+ }
+
+ onPageChange = (link: string) => {
+ this.props.fetchGroupsByLink(link);
+ };
+
+ /**
+ * reflect page transitions in the uri
+ */
+ componentDidUpdate = (prevProps: Props) => {
+ const { page, list } = this.props;
+ if (list.page >= 0) {
+ // backend starts paging by 0
+ const statePage: number = list.page + 1;
+ if (page !== statePage) {
+ this.props.history.push(`/groups/${statePage}`);
+ }
+ }
+ };
+
+ render() {
+ const { groups, loading, error, t } = this.props;
+ return (
+
+
+ {this.renderPaginator()}
+ {this.renderCreateButton()}
+
+ );
+ }
+
+ renderPaginator() {
+ const { list } = this.props;
+ if (list) {
+ return ;
+ }
+ return null;
+ }
+
+ renderCreateButton() {
+ if (this.props.canAddGroups) {
+ return ;
+ } else {
+ return;
+ }
+ }
+}
+
+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 groups = getGroupsFromState(state);
+ const loading = isFetchGroupsPending(state);
+ const error = getFetchGroupsFailure(state);
+
+ const page = getPageFromProps(ownProps);
+ const canAddGroups = isPermittedToCreateGroups(state);
+ const list = selectListAsCollection(state);
+
+ return {
+ groups,
+ loading,
+ error,
+ canAddGroups,
+ list,
+ page
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchGroupsByPage: (page: number) => {
+ dispatch(fetchGroupsByPage(page));
+ },
+ fetchGroupsByLink: (link: string) => {
+ dispatch(fetchGroupsByLink(link));
+ }
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(translate("groups")(Groups));
diff --git a/scm-ui/src/groups/containers/SingleGroup.js b/scm-ui/src/groups/containers/SingleGroup.js
new file mode 100644
index 0000000000..781d0bb904
--- /dev/null
+++ b/scm-ui/src/groups/containers/SingleGroup.js
@@ -0,0 +1,143 @@
+//@flow
+import React from "react";
+import { connect } from "react-redux";
+import { Page } from "../../components/layout";
+import { Route } from "react-router";
+import { Details } from "./../components/table";
+import { DeleteGroupNavLink, EditGroupNavLink } from "./../components/navLinks";
+import type { Group } from "../types/Group";
+import type { History } from "history";
+import {
+ deleteGroup,
+ fetchGroup,
+ getGroupByName,
+ isFetchGroupPending,
+ getFetchGroupFailure,
+ getDeleteGroupFailure,
+ isDeleteGroupPending,
+} from "../modules/groups";
+import Loading from "../../components/Loading";
+
+import { Navigation, Section, NavLink } from "../../components/navigation";
+import ErrorPage from "../../components/ErrorPage";
+import { translate } from "react-i18next";
+import EditGroup from "./EditGroup";
+
+type Props = {
+ name: string,
+ group: Group,
+ loading: boolean,
+ error: Error,
+
+ // dispatcher functions
+ deleteGroup: (group: Group, callback?: () => void) => void,
+ fetchGroup: string => void,
+
+ // context objects
+ t: string => string,
+ match: any,
+ history: History
+};
+
+class SingleGroup extends React.Component {
+ componentDidMount() {
+ this.props.fetchGroup(this.props.name);
+ }
+
+ stripEndingSlash = (url: string) => {
+ if (url.endsWith("/")) {
+ return url.substring(0, url.length - 2);
+ }
+ return url;
+ };
+
+ deleteGroup = (group: Group) => {
+ this.props.deleteGroup(group, this.groupDeleted);
+ };
+
+ groupDeleted = () => {
+ this.props.history.push("/groups");
+ };
+
+ matchedUrl = () => {
+ return this.stripEndingSlash(this.props.match.url);
+ };
+
+ render() {
+ const { t, loading, error, group } = this.props;
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!group || loading) {
+ return ;
+ }
+
+ const url = this.matchedUrl();
+
+ return (
+
+
+
+ } />
+ } />
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ const name = ownProps.match.params.name;
+ const group = getGroupByName(state, name);
+ const loading =
+ isFetchGroupPending(state, name) || isDeleteGroupPending(state, name);
+ const error =
+ getFetchGroupFailure(state, name) || getDeleteGroupFailure(state, name);
+
+ return {
+ name,
+ group,
+ loading,
+ error
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchGroup: (name: string) => {
+ dispatch(fetchGroup(name));
+ },
+ deleteGroup: (group: Group, callback?: () => void) => {
+ dispatch(deleteGroup(group, callback));
+ }
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(translate("groups")(SingleGroup));
diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js
new file mode 100644
index 0000000000..324faa7abb
--- /dev/null
+++ b/scm-ui/src/groups/modules/groups.js
@@ -0,0 +1,473 @@
+// @flow
+import { apiClient } from "../../apiclient";
+import { isPending } from "../../modules/pending";
+import { getFailure } from "../../modules/failure";
+import * as types from "../../modules/types";
+import { combineReducers, Dispatch } from "redux";
+import type { Action } from "../../types/Action";
+import type { PagedCollection } from "../../types/Collection";
+import type { Group } from "../types/Group";
+
+export const FETCH_GROUPS = "scm/groups/FETCH_GROUPS";
+export const FETCH_GROUPS_PENDING = `${FETCH_GROUPS}_${types.PENDING_SUFFIX}`;
+export const FETCH_GROUPS_SUCCESS = `${FETCH_GROUPS}_${types.SUCCESS_SUFFIX}`;
+export const FETCH_GROUPS_FAILURE = `${FETCH_GROUPS}_${types.FAILURE_SUFFIX}`;
+
+export const FETCH_GROUP = "scm/groups/FETCH_GROUP";
+export const FETCH_GROUP_PENDING = `${FETCH_GROUP}_${types.PENDING_SUFFIX}`;
+export const FETCH_GROUP_SUCCESS = `${FETCH_GROUP}_${types.SUCCESS_SUFFIX}`;
+export const FETCH_GROUP_FAILURE = `${FETCH_GROUP}_${types.FAILURE_SUFFIX}`;
+
+export const CREATE_GROUP = "scm/groups/CREATE_GROUP";
+export const CREATE_GROUP_PENDING = `${CREATE_GROUP}_${types.PENDING_SUFFIX}`;
+export const CREATE_GROUP_SUCCESS = `${CREATE_GROUP}_${types.SUCCESS_SUFFIX}`;
+export const CREATE_GROUP_FAILURE = `${CREATE_GROUP}_${types.FAILURE_SUFFIX}`;
+export const CREATE_GROUP_RESET = `${CREATE_GROUP}_${types.RESET_SUFFIX}`;
+
+export const MODIFY_GROUP = "scm/groups/MODIFY_GROUP";
+export const MODIFY_GROUP_PENDING = `${MODIFY_GROUP}_${types.PENDING_SUFFIX}`;
+export const MODIFY_GROUP_SUCCESS = `${MODIFY_GROUP}_${types.SUCCESS_SUFFIX}`;
+export const MODIFY_GROUP_FAILURE = `${MODIFY_GROUP}_${types.FAILURE_SUFFIX}`;
+
+export const DELETE_GROUP = "scm/groups/DELETE";
+export const DELETE_GROUP_PENDING = `${DELETE_GROUP}_${types.PENDING_SUFFIX}`;
+export const DELETE_GROUP_SUCCESS = `${DELETE_GROUP}_${types.SUCCESS_SUFFIX}`;
+export const DELETE_GROUP_FAILURE = `${DELETE_GROUP}_${types.FAILURE_SUFFIX}`;
+
+const GROUPS_URL = "groups";
+const CONTENT_TYPE_GROUP = "application/vnd.scmm-group+json;v=2";
+
+// fetch groups
+export function fetchGroups() {
+ return fetchGroupsByLink(GROUPS_URL);
+}
+
+export function fetchGroupsByPage(page: number) {
+ // backend start counting by 0
+ return fetchGroupsByLink(GROUPS_URL + "?page=" + (page - 1));
+}
+
+export function fetchGroupsByLink(link: string) {
+ return function(dispatch: any) {
+ dispatch(fetchGroupsPending());
+ return apiClient
+ .get(link)
+ .then(response => response.json())
+ .then(data => {
+ dispatch(fetchGroupsSuccess(data));
+ })
+ .catch(cause => {
+ const error = new Error(`could not fetch groups: ${cause.message}`);
+ dispatch(fetchGroupsFailure(GROUPS_URL, error));
+ });
+ };
+}
+
+export function fetchGroupsPending(): Action {
+ return {
+ type: FETCH_GROUPS_PENDING
+ };
+}
+
+export function fetchGroupsSuccess(groups: any): Action {
+ return {
+ type: FETCH_GROUPS_SUCCESS,
+ payload: groups
+ };
+}
+
+export function fetchGroupsFailure(url: string, error: Error): Action {
+ return {
+ type: FETCH_GROUPS_FAILURE,
+ payload: {
+ error,
+ url
+ }
+ };
+}
+
+//fetch group
+export function fetchGroup(name: string) {
+ const groupUrl = GROUPS_URL + "/" + name;
+ return function(dispatch: any) {
+ dispatch(fetchGroupPending(name));
+ return apiClient
+ .get(groupUrl)
+ .then(response => {
+ return response.json();
+ })
+ .then(data => {
+ dispatch(fetchGroupSuccess(data));
+ })
+ .catch(cause => {
+ const error = new Error(`could not fetch group: ${cause.message}`);
+ dispatch(fetchGroupFailure(name, error));
+ });
+ };
+}
+
+export function fetchGroupPending(name: string): Action {
+ return {
+ type: FETCH_GROUP_PENDING,
+ payload: name,
+ itemId: name
+ };
+}
+
+export function fetchGroupSuccess(group: any): Action {
+ return {
+ type: FETCH_GROUP_SUCCESS,
+ payload: group,
+ itemId: group.name
+ };
+}
+
+export function fetchGroupFailure(name: string, error: Error): Action {
+ return {
+ type: FETCH_GROUP_FAILURE,
+ payload: {
+ name,
+ error
+ },
+ itemId: name
+ };
+}
+
+//create group
+export function createGroup(group: Group, callback?: () => void) {
+ return function(dispatch: Dispatch) {
+ dispatch(createGroupPending());
+ return apiClient
+ .post(GROUPS_URL, group, CONTENT_TYPE_GROUP)
+ .then(() => {
+ dispatch(createGroupSuccess());
+ if (callback) {
+ callback();
+ }
+ })
+ .catch(error => {
+ dispatch(
+ createGroupFailure(
+ new Error(`Failed to create group ${group.name}: ${error.message}`)
+ )
+ );
+ });
+ };
+}
+
+export function createGroupPending() {
+ return {
+ type: CREATE_GROUP_PENDING
+ };
+}
+
+export function createGroupSuccess() {
+ return {
+ type: CREATE_GROUP_SUCCESS
+ };
+}
+
+export function createGroupFailure(error: Error) {
+ return {
+ type: CREATE_GROUP_FAILURE,
+ payload: error
+ };
+}
+
+export function createGroupReset() {
+ return {
+ type: CREATE_GROUP_RESET
+ };
+}
+
+// modify group
+export function modifyGroup(group: Group, callback?: () => void) {
+ return function(dispatch: Dispatch) {
+ dispatch(modifyGroupPending(group));
+ return apiClient
+ .put(group._links.update.href, group, CONTENT_TYPE_GROUP)
+ .then(() => {
+ dispatch(modifyGroupSuccess(group));
+ if (callback) {
+ callback();
+ }
+ })
+ .catch(cause => {
+ dispatch(
+ modifyGroupFailure(
+ group,
+ new Error(`could not modify group ${group.name}: ${cause.message}`)
+ )
+ );
+ });
+ };
+}
+
+export function modifyGroupPending(group: Group): Action {
+ return {
+ type: MODIFY_GROUP_PENDING,
+ payload: group,
+ itemId: group.name
+ };
+}
+
+export function modifyGroupSuccess(group: Group): Action {
+ return {
+ type: MODIFY_GROUP_SUCCESS,
+ payload: group,
+ itemId: group.name
+ };
+}
+
+export function modifyGroupFailure(group: Group, error: Error): Action {
+ return {
+ type: MODIFY_GROUP_FAILURE,
+ payload: {
+ error,
+ group
+ },
+ itemId: group.name
+ };
+}
+
+//delete group
+
+export function deleteGroup(group: Group, callback?: () => void) {
+ return function(dispatch: any) {
+ dispatch(deleteGroupPending(group));
+ return apiClient
+ .delete(group._links.delete.href)
+ .then(() => {
+ dispatch(deleteGroupSuccess(group));
+ if (callback) {
+ callback();
+ }
+ })
+ .catch(cause => {
+ const error = new Error(
+ `could not delete group ${group.name}: ${cause.message}`
+ );
+ dispatch(deleteGroupFailure(group, error));
+ });
+ };
+}
+
+export function deleteGroupPending(group: Group): Action {
+ return {
+ type: DELETE_GROUP_PENDING,
+ payload: group,
+ itemId: group.name
+ };
+}
+
+export function deleteGroupSuccess(group: Group): Action {
+ return {
+ type: DELETE_GROUP_SUCCESS,
+ payload: group,
+ itemId: group.name
+ };
+}
+
+export function deleteGroupFailure(group: Group, error: Error): Action {
+ return {
+ type: DELETE_GROUP_FAILURE,
+ payload: {
+ error,
+ group
+ },
+ itemId: group.name
+ };
+}
+
+//reducer
+function extractGroupsByNames(
+ groups: Group[],
+ groupNames: string[],
+ oldGroupsByNames: Object
+) {
+ const groupsByNames = {};
+
+ for (let group of groups) {
+ groupsByNames[group.name] = group;
+ }
+
+ for (let groupName in oldGroupsByNames) {
+ groupsByNames[groupName] = oldGroupsByNames[groupName];
+ }
+ return groupsByNames;
+}
+
+function deleteGroupInGroupsByNames(groups: {}, groupName: string) {
+ let newGroups = {};
+ for (let groupname in groups) {
+ if (groupname !== groupName) newGroups[groupname] = groups[groupname];
+ }
+ return newGroups;
+}
+
+function deleteGroupInEntries(groups: [], groupName: string) {
+ let newGroups = [];
+ for (let group of groups) {
+ if (group !== groupName) newGroups.push(group);
+ }
+ return newGroups;
+}
+
+const reducerByName = (state: any, groupname: string, newGroupState: any) => {
+ const newGroupsByNames = {
+ ...state,
+ [groupname]: newGroupState
+ };
+
+ return newGroupsByNames;
+};
+
+function listReducer(state: any = {}, action: any = {}) {
+ switch (action.type) {
+ case FETCH_GROUPS_SUCCESS:
+ const groups = action.payload._embedded.groups;
+ const groupNames = groups.map(group => group.name);
+ return {
+ ...state,
+ entries: groupNames,
+ entry: {
+ groupCreatePermission: action.payload._links.create ? true : false,
+ page: action.payload.page,
+ pageTotal: action.payload.pageTotal,
+ _links: action.payload._links
+ }
+ };
+ // Delete single group actions
+ case DELETE_GROUP_SUCCESS:
+ const newGroupEntries = deleteGroupInEntries(
+ state.entries,
+ action.payload.name
+ );
+ return {
+ ...state,
+ entries: newGroupEntries
+ };
+ default:
+ return state;
+ }
+}
+
+function byNamesReducer(state: any = {}, action: any = {}) {
+ switch (action.type) {
+ // Fetch all groups actions
+ case FETCH_GROUPS_SUCCESS:
+ const groups = action.payload._embedded.groups;
+ const groupNames = groups.map(group => group.name);
+ const byNames = extractGroupsByNames(groups, groupNames, state.byNames);
+ return {
+ ...byNames
+ };
+ case FETCH_GROUP_SUCCESS:
+ return reducerByName(state, action.payload.name, action.payload);
+ case MODIFY_GROUP_SUCCESS:
+ return reducerByName(state, action.payload.name, action.payload);
+ case DELETE_GROUP_SUCCESS:
+ const newGroupByNames = deleteGroupInGroupsByNames(
+ state,
+ action.payload.name
+ );
+ return newGroupByNames;
+
+ default:
+ return state;
+ }
+}
+
+export default combineReducers({
+ list: listReducer,
+ byNames: byNamesReducer
+});
+
+// selectors
+
+const selectList = (state: Object) => {
+ if (state.groups && state.groups.list) {
+ return state.groups.list;
+ }
+ return {};
+};
+
+const selectListEntry = (state: Object): Object => {
+ const list = selectList(state);
+ if (list.entry) {
+ return list.entry;
+ }
+ return {};
+};
+
+export const selectListAsCollection = (state: Object): PagedCollection => {
+ return selectListEntry(state);
+};
+
+export const isPermittedToCreateGroups = (state: Object): boolean => {
+ const permission = selectListEntry(state).groupCreatePermission;
+ if (permission) {
+ return true;
+ }
+ return false;
+};
+
+export function getGroupsFromState(state: Object) {
+ const groupNames = selectList(state).entries;
+ if (!groupNames) {
+ return null;
+ }
+ const groupEntries: Group[] = [];
+
+ for (let groupName of groupNames) {
+ groupEntries.push(state.groups.byNames[groupName]);
+ }
+
+ return groupEntries;
+}
+
+export function isFetchGroupsPending(state: Object) {
+ return isPending(state, FETCH_GROUPS);
+}
+
+export function getFetchGroupsFailure(state: Object) {
+ return getFailure(state, FETCH_GROUPS);
+}
+
+export function isCreateGroupPending(state: Object) {
+ return isPending(state, CREATE_GROUP);
+}
+
+export function getCreateGroupFailure(state: Object) {
+ return getFailure(state, CREATE_GROUP);
+}
+
+export function isModifyGroupPending(state: Object, name: string) {
+ return isPending(state, MODIFY_GROUP, name);
+}
+
+export function getModifyGroupFailure(state: Object, name: string) {
+ return getFailure(state, MODIFY_GROUP, name);
+}
+
+export function getGroupByName(state: Object, name: string) {
+ if (state.groups && state.groups.byNames) {
+ return state.groups.byNames[name];
+ }
+}
+
+export function isFetchGroupPending(state: Object, name: string) {
+ return isPending(state, FETCH_GROUP, name);
+}
+
+export function getFetchGroupFailure(state: Object, name: string) {
+ return getFailure(state, FETCH_GROUP, name);
+}
+
+export function isDeleteGroupPending(state: Object, name: string) {
+ return isPending(state, DELETE_GROUP, name);
+}
+
+export function getDeleteGroupFailure(state: Object, name: string) {
+ return getFailure(state, DELETE_GROUP, name);
+}
diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js
new file mode 100644
index 0000000000..6914590f35
--- /dev/null
+++ b/scm-ui/src/groups/modules/groups.test.js
@@ -0,0 +1,632 @@
+//@flow
+import configureMockStore from "redux-mock-store";
+import thunk from "redux-thunk";
+import fetchMock from "fetch-mock";
+
+import reducer, {
+ fetchGroups,
+ FETCH_GROUPS,
+ FETCH_GROUPS_PENDING,
+ FETCH_GROUPS_SUCCESS,
+ FETCH_GROUPS_FAILURE,
+ fetchGroupsSuccess,
+ isPermittedToCreateGroups,
+ getGroupsFromState,
+ getFetchGroupsFailure,
+ isFetchGroupsPending,
+ selectListAsCollection,
+ fetchGroup,
+ FETCH_GROUP_PENDING,
+ FETCH_GROUP_SUCCESS,
+ FETCH_GROUP_FAILURE,
+ fetchGroupSuccess,
+ getFetchGroupFailure,
+ FETCH_GROUP,
+ isFetchGroupPending,
+ getGroupByName,
+ createGroup,
+ CREATE_GROUP_SUCCESS,
+ CREATE_GROUP_PENDING,
+ CREATE_GROUP_FAILURE,
+ isCreateGroupPending,
+ CREATE_GROUP,
+ getCreateGroupFailure,
+ deleteGroup,
+ DELETE_GROUP_PENDING,
+ DELETE_GROUP_SUCCESS,
+ DELETE_GROUP_FAILURE,
+ DELETE_GROUP,
+ deleteGroupSuccess,
+ isDeleteGroupPending,
+ getDeleteGroupFailure,
+ modifyGroup,
+ MODIFY_GROUP_PENDING,
+ MODIFY_GROUP_SUCCESS,
+ MODIFY_GROUP_FAILURE
+} from "./groups";
+const GROUPS_URL = "/scm/api/rest/v2/groups";
+
+const error = new Error("You have an error!");
+
+const humanGroup = {
+ creationDate: "2018-07-31T08:39:07.860Z",
+ description: "This is a group",
+ name: "humanGroup",
+ type: "xml",
+ properties: {},
+ members: ["userZaphod"],
+ _links: {
+ self: {
+ href: "http://localhost:8081/scm/api/rest/v2/groups/humanGroup"
+ },
+ delete: {
+ href: "http://localhost:8081/scm/api/rest/v2/groups/humanGroup"
+ },
+ update: {
+ href:"http://localhost:8081/scm/api/rest/v2/groups/humanGroup"
+ }
+ },
+ _embedded: {
+ members: [
+ {
+ name: "userZaphod",
+ _links: {
+ self: {
+ href: "http://localhost:8081/scm/api/rest/v2/users/userZaphod"
+ }
+ }
+ }
+ ]
+ }
+};
+
+const emptyGroup = {
+ creationDate: "2018-07-31T08:39:07.860Z",
+ description: "This is a group",
+ name: "emptyGroup",
+ type: "xml",
+ properties: {},
+ members: [],
+ _links: {
+ self: {
+ href: "http://localhost:8081/scm/api/rest/v2/groups/emptyGroup"
+ },
+ delete: {
+ href: "http://localhost:8081/scm/api/rest/v2/groups/emptyGroup"
+ },
+ update: {
+ href:"http://localhost:8081/scm/api/rest/v2/groups/emptyGroup"
+ }
+ },
+ _embedded: {
+ members: []
+ }
+};
+
+const responseBody = {
+ page: 0,
+ pageTotal: 1,
+ _links: {
+ self: {
+ href: "http://localhost:3000/scm/api/rest/v2/groups/?page=0&pageSize=10"
+ },
+ first: {
+ href: "http://localhost:3000/scm/api/rest/v2/groups/?page=0&pageSize=10"
+ },
+ last: {
+ href: "http://localhost:3000/scm/api/rest/v2/groups/?page=0&pageSize=10"
+ },
+ create: {
+ href: "http://localhost:3000/scm/api/rest/v2/groups/"
+ }
+ },
+ _embedded: {
+ groups: [humanGroup, emptyGroup]
+ }
+};
+
+const response = {
+ headers: { "content-type": "application/json" },
+ responseBody
+};
+
+describe("groups fetch()", () => {
+ const mockStore = configureMockStore([thunk]);
+ afterEach(() => {
+ fetchMock.reset();
+ fetchMock.restore();
+ });
+
+ it("should successfully fetch groups", () => {
+ fetchMock.getOnce(GROUPS_URL, response);
+
+ const expectedActions = [
+ { type: FETCH_GROUPS_PENDING },
+ {
+ type: FETCH_GROUPS_SUCCESS,
+ payload: response
+ }
+ ];
+
+ const store = mockStore({});
+
+ return store.dispatch(fetchGroups()).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
+ it("should fail getting groups on HTTP 500", () => {
+ fetchMock.getOnce(GROUPS_URL, {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(fetchGroups()).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(FETCH_GROUPS_PENDING);
+ expect(actions[1].type).toEqual(FETCH_GROUPS_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should sucessfully fetch single group", () => {
+ fetchMock.getOnce(GROUPS_URL + "/humanGroup", humanGroup);
+
+ const store = mockStore({});
+ return store.dispatch(fetchGroup("humanGroup")).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
+ expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+ it("should fail fetching single group on HTTP 500", () => {
+ fetchMock.getOnce(GROUPS_URL + "/humanGroup", {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(fetchGroup("humanGroup")).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
+ expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+
+ it("should successfully create group", () => {
+ fetchMock.postOnce(GROUPS_URL, {
+ status: 201
+ });
+
+ const store = mockStore({});
+ return store.dispatch(createGroup(humanGroup)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
+ expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
+ });
+ });
+
+ it("should call the callback after creating group", () => {
+ fetchMock.postOnce(GROUPS_URL, {
+ status: 201
+ });
+ let called = false;
+
+ const callMe = () => {
+ called = true;
+ }
+ const store = mockStore({});
+ return store.dispatch(createGroup(humanGroup, callMe)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
+ expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
+ expect(called).toEqual(true);
+ });
+ });
+
+
+ it("should fail creating group on HTTP 500", () => {
+ fetchMock.postOnce(GROUPS_URL, {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(createGroup(humanGroup)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
+ expect(actions[1].type).toEqual(CREATE_GROUP_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ expect(actions[1].payload instanceof Error).toBeTruthy();
+ });
+ });
+
+ it("should successfully modify group", () => {
+ fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
+ status: 204
+ });
+
+ const store = mockStore({});
+
+ return store.dispatch(modifyGroup(humanGroup)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
+ expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS);
+ expect(actions[1].payload).toEqual(humanGroup)
+ });
+ })
+
+ it("should call the callback after modifying group", () => {
+ fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
+ status: 204
+ });
+
+ let called = false;
+ const callback = () => {
+ called = true;
+ }
+ const store = mockStore({});
+
+ return store.dispatch(modifyGroup(humanGroup, callback)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
+ expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS);
+ expect(called).toBe(true);
+ });
+ })
+
+ it("should fail modifying group on HTTP 500", () => {
+ fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
+ status: 500
+ });
+
+ const store = mockStore({});
+
+ return store.dispatch(modifyGroup(humanGroup)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
+ expect(actions[1].type).toEqual(MODIFY_GROUP_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ })
+
+ it("should delete successfully group humanGroup", () => {
+ fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
+ status: 204
+ });
+
+ const store = mockStore({});
+ return store.dispatch(deleteGroup(humanGroup)).then(() => {
+ const actions = store.getActions();
+ expect(actions.length).toBe(2);
+ expect(actions[0].type).toEqual(DELETE_GROUP_PENDING);
+ expect(actions[0].payload).toBe(humanGroup);
+ expect(actions[1].type).toEqual(DELETE_GROUP_SUCCESS);
+ });
+ });
+
+ it("should call the callback, after successful delete", () => {
+ fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
+ status: 204
+ });
+
+ let called = false;
+ const callMe = () => {
+ called = true;
+ };
+
+ const store = mockStore({});
+ return store.dispatch(deleteGroup(humanGroup, callMe)).then(() => {
+ expect(called).toBeTruthy();
+ });
+ });
+
+ it("should fail to delete group humanGroup", () => {
+ fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
+ status: 500
+ });
+
+ const store = mockStore({});
+ return store.dispatch(deleteGroup(humanGroup)).then(() => {
+ const actions = store.getActions();
+ expect(actions[0].type).toEqual(DELETE_GROUP_PENDING);
+ expect(actions[0].payload).toBe(humanGroup);
+ expect(actions[1].type).toEqual(DELETE_GROUP_FAILURE);
+ expect(actions[1].payload).toBeDefined();
+ });
+ });
+
+});
+
+describe("groups reducer", () => {
+
+ it("should update state correctly according to FETCH_GROUPS_SUCCESS action", () => {
+
+ const newState = reducer({}, fetchGroupsSuccess(responseBody));
+
+ expect(newState.list).toEqual({
+ entries: ["humanGroup", "emptyGroup"],
+ entry: {
+ groupCreatePermission: true,
+ page: 0,
+ pageTotal: 1,
+ _links: responseBody._links
+ }
+ });
+
+ expect(newState.byNames).toEqual({
+ humanGroup: humanGroup,
+ emptyGroup: emptyGroup
+ });
+
+ expect(newState.list.entry.groupCreatePermission).toBeTruthy();
+ });
+
+ it("should set groupCreatePermission to true if update link is present", () => {
+ const newState = reducer({}, fetchGroupsSuccess(responseBody));
+
+ expect(newState.list.entry.groupCreatePermission).toBeTruthy();
+ });
+
+ it("should not replace whole byNames map when fetching groups", () => {
+ const oldState = {
+ byNames: {
+ emptyGroup: emptyGroup
+ }
+ };
+
+ const newState = reducer(oldState, fetchGroupsSuccess(responseBody));
+ expect(newState.byNames["humanGroup"]).toBeDefined();
+ expect(newState.byNames["emptyGroup"]).toBeDefined();
+ });
+
+ it("should set groupCreatePermission to true if create link is present", () => {
+ const newState = reducer({}, fetchGroupsSuccess(responseBody));
+
+ expect(newState.list.entry.groupCreatePermission).toBeTruthy();
+ expect(newState.list.entries).toEqual(["humanGroup", "emptyGroup"]);
+ expect(newState.byNames["emptyGroup"]).toBeTruthy();
+ expect(newState.byNames["humanGroup"]).toBeTruthy();
+ });
+
+
+ it("should update state according to FETCH_GROUP_SUCCESS action", () => {
+ const newState = reducer({}, fetchGroupSuccess(emptyGroup));
+ expect(newState.byNames["emptyGroup"]).toBe(emptyGroup);
+ });
+
+ it("should affect groups state nor the state of other groups", () => {
+ const newState = reducer(
+ {
+ list: {
+ entries: ["humanGroup"]
+ }
+ },
+ fetchGroupSuccess(emptyGroup)
+ );
+ expect(newState.byNames["emptyGroup"]).toBe(emptyGroup);
+ expect(newState.list.entries).toEqual(["humanGroup"]);
+ });
+
+ it("should remove group from state when delete succeeds", () => {
+ const state = {
+ list: {
+ entries: ["humanGroup", "emptyGroup"]
+ },
+ byNames: {
+ humanGroup: humanGroup,
+ emptyGroup: emptyGroup
+ }
+ };
+
+ const newState = reducer(state, deleteGroupSuccess(emptyGroup));
+ expect(newState.byNames["humanGroup"]).toBeDefined();
+ expect(newState.byNames["emptyGroup"]).toBeFalsy();
+ expect(newState.list.entries).toEqual(["humanGroup"]);
+ });
+
+});
+
+describe("selector tests", () => {
+ it("should return an empty object", () => {
+ expect(selectListAsCollection({})).toEqual({});
+ expect(selectListAsCollection({ groups: { a: "a" } })).toEqual({});
+ });
+
+ it("should return a state slice collection", () => {
+ const collection = {
+ page: 3,
+ totalPages: 42
+ };
+
+ const state = {
+ groups: {
+ list: {
+ entry: collection
+ }
+ }
+ };
+ expect(selectListAsCollection(state)).toBe(collection);
+ });
+
+ it("should return false when groupCreatePermission is false", () => {
+ expect(isPermittedToCreateGroups({})).toBe(false);
+ expect(isPermittedToCreateGroups({ groups: { list: { entry: {} } } })).toBe(
+ false
+ );
+ expect(
+ isPermittedToCreateGroups({
+ groups: { list: { entry: { groupCreatePermission: false } } }
+ })
+ ).toBe(false);
+ });
+
+ it("should return true when groupCreatePermission is true", () => {
+ const state = {
+ groups: {
+ list: {
+ entry: {
+ groupCreatePermission: true
+ }
+ }
+ }
+ };
+ expect(isPermittedToCreateGroups(state)).toBe(true);
+ });
+
+ it("should get groups from state", () => {
+ const state = {
+ groups: {
+ list: {
+ entries: ["a", "b"]
+ },
+ byNames: {
+ a: { name: "a" },
+ b: { name: "b" }
+ }
+ }
+ };
+
+ expect(getGroupsFromState(state)).toEqual([{ name: "a" }, { name: "b" }]);
+ });
+
+ it("should return null when there are no groups in the state", () => {
+ expect(getGroupsFromState({})).toBe(null)
+ });
+
+ it("should return true, when fetch groups is pending", () => {
+ const state = {
+ pending: {
+ [FETCH_GROUPS]: true
+ }
+ };
+ expect(isFetchGroupsPending(state)).toEqual(true);
+ });
+
+ it("should return false, when fetch groups is not pending", () => {
+ expect(isFetchGroupsPending({})).toEqual(false);
+ });
+
+ it("should return error when fetch groups did fail", () => {
+ const state = {
+ failure: {
+ [FETCH_GROUPS]: error
+ }
+ };
+ expect(getFetchGroupsFailure(state)).toEqual(error);
+ });
+
+ it("should return undefined when fetch groups did not fail", () => {
+ expect(getFetchGroupsFailure({})).toBe(undefined);
+ });
+
+ it("should return group emptyGroup", () => {
+ const state = {
+ groups: {
+ byNames: {
+ emptyGroup: emptyGroup
+ }
+ }
+ };
+ expect(getGroupByName(state, "emptyGroup")).toEqual(emptyGroup);
+ });
+
+ it("should return true, when fetch group humanGroup is pending", () => {
+ const state = {
+ pending: {
+ [FETCH_GROUP + "/humanGroup"]: true
+ }
+ };
+ expect(isFetchGroupPending(state, "humanGroup")).toEqual(true);
+ });
+
+ it("should return false, when fetch group humanGroup is not pending", () => {
+ expect(isFetchGroupPending({}, "humanGroup")).toEqual(false);
+ });
+
+ it("should return error when fetch group humanGroup did fail", () => {
+ const state = {
+ failure: {
+ [FETCH_GROUP + "/humanGroup"]: error
+ }
+ };
+ expect(getFetchGroupFailure(state, "humanGroup")).toEqual(error);
+ });
+
+ it("should return undefined when fetch group humanGroup did not fail", () => {
+ expect(getFetchGroupFailure({}, "humanGroup")).toBe(undefined);
+ });
+
+ it("should return true if create group is pending", () => {
+ expect(isCreateGroupPending({pending: {
+ [CREATE_GROUP]: true
+ }})).toBeTruthy();
+ })
+
+ it("should return false if create group is not pending", () => {
+ expect(isCreateGroupPending({})).toBe(false);
+ })
+
+ it("should return error if creating group failed", () => {
+ expect(getCreateGroupFailure({
+ failure: {
+ [CREATE_GROUP]: error
+ }
+ })).toEqual(error)
+ })
+
+ it("should return undefined if creating group did not fail", () => {
+ expect(getCreateGroupFailure({})).toBeUndefined()
+ })
+
+
+ it("should return true, when delete group humanGroup is pending", () => {
+ const state = {
+ pending: {
+ [DELETE_GROUP + "/humanGroup"]: true
+ }
+ };
+ expect(isDeleteGroupPending(state, "humanGroup")).toEqual(true);
+ });
+
+ it("should return false, when delete group humanGroup is not pending", () => {
+ expect(isDeleteGroupPending({}, "humanGroup")).toEqual(false);
+ });
+
+ it("should return error when delete group humanGroup did fail", () => {
+ const state = {
+ failure: {
+ [DELETE_GROUP + "/humanGroup"]: error
+ }
+ };
+ expect(getDeleteGroupFailure(state, "humanGroup")).toEqual(error);
+ });
+
+ it("should return undefined when delete group humanGroup did not fail", () => {
+ expect(getDeleteGroupFailure({}, "humanGroup")).toBe(undefined);
+ });
+
+ it("should return true, if createGroup is pending", () => {
+ const state = {
+ pending: {
+ [CREATE_GROUP]: true
+ }
+ }
+ expect(isCreateGroupPending(state)).toBe(true);
+ })
+
+ it("should return false, if createGroup is not pending", () => {
+ expect(isCreateGroupPending({})).toBe(false)
+ })
+
+ it("should return error of createGroup failed", () => {
+ const state = {
+ failure: {
+ [CREATE_GROUP]: error
+ }
+ }
+ expect(getCreateGroupFailure(state)).toEqual(error)
+ })
+});
diff --git a/scm-ui/src/groups/types/Group.js b/scm-ui/src/groups/types/Group.js
new file mode 100644
index 0000000000..420d724784
--- /dev/null
+++ b/scm-ui/src/groups/types/Group.js
@@ -0,0 +1,18 @@
+//@flow
+import type { Collection } from "../../types/Collection";
+import type { Links } from "../../types/hal";
+
+export type Member = {
+ name: string,
+ _links: Links
+};
+
+export type Group = Collection & {
+ name: string,
+ description: string,
+ type: string,
+ members: string[],
+ _embedded: {
+ members: Member[]
+ }
+};
diff --git a/scm-ui/src/i18n.js b/scm-ui/src/i18n.js
index 954d47c605..cb8705a746 100644
--- a/scm-ui/src/i18n.js
+++ b/scm-ui/src/i18n.js
@@ -5,6 +5,8 @@ import { reactI18nextModule } from "react-i18next";
const loadPath = process.env.PUBLIC_URL + "/locales/{{lng}}/{{ns}}.json";
+// TODO load locales for moment
+
i18n
.use(Backend)
.use(LanguageDetector)
diff --git a/scm-ui/src/repos/components/DeleteNavAction.js b/scm-ui/src/repos/components/DeleteNavAction.js
new file mode 100644
index 0000000000..0fd554f045
--- /dev/null
+++ b/scm-ui/src/repos/components/DeleteNavAction.js
@@ -0,0 +1,59 @@
+//@flow
+import React from "react";
+import { translate } from "react-i18next";
+import { confirmAlert } from "../../components/modals/ConfirmAlert";
+import { NavAction } from "../../components/navigation";
+import type { Repository } from "../types/Repositories";
+
+type Props = {
+ repository: Repository,
+ confirmDialog?: boolean,
+ delete: Repository => 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..1e6295442e 100644
--- a/scm-ui/src/users/components/userValidation.js
+++ b/scm-ui/src/users/components/userValidation.js
@@ -1,10 +1,8 @@
// @flow
-const nameRegex = /^([A-z0-9.\-_@]|[^ ]([A-z0-9.\-_@ ]*[A-z0-9.\-_@]|[^\s])?)$/;
+import { isNameValid, isMailValid } from "../../components/validation";
-export const isNameValid = (name: string) => {
- return nameRegex.test(name);
-};
+export { isNameValid, isMailValid };
export const isDisplayNameValid = (displayName: string) => {
if (displayName) {
@@ -12,13 +10,6 @@ export const isDisplayNameValid = (displayName: string) => {
}
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..9bcc38cf3b 100644
--- a/scm-ui/src/users/containers/AddUser.js
+++ b/scm-ui/src/users/containers/AddUser.js
@@ -1,7 +1,7 @@
//@flow
import React from "react";
import { connect } from "react-redux";
-import UserForm from "./../components/UserForm";
+import UserForm from "../components/UserForm";
import type { User } from "../types/User";
import type { History } from "history";
import {
@@ -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..eb64c56214 100644
--- a/scm-ui/src/users/containers/Users.js
+++ b/scm-ui/src/users/containers/Users.js
@@ -50,16 +50,16 @@ 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) {
this.props.history.push(`/users/${statePage}`);
}
}
- };
+ }
render() {
const { users, loading, error, t } = this.props;
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/api/v2/resources/BaseMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java
index 94d884c437..db24463be8 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BaseMapper.java
@@ -2,14 +2,13 @@ package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import org.mapstruct.Mapping;
-import sonia.scm.ModelObject;
import java.time.Instant;
-abstract class BaseMapper {
+abstract class BaseMapper {
@Mapping(target = "attributes", ignore = true) // We do not map HAL attributes
- public abstract D map(T modelObject);
+ public abstract D map(T object);
Instant mapTime(Long epochMilli) {
return epochMilli == null? null: Instant.ofEpochMilli(epochMilli);
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/CollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/CollectionToDtoMapper.java
new file mode 100644
index 0000000000..4f8c6a6f3f
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/CollectionToDtoMapper.java
@@ -0,0 +1,32 @@
+package sonia.scm.api.v2.resources;
+
+import de.otto.edison.hal.HalRepresentation;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static de.otto.edison.hal.Embedded.embeddedBuilder;
+import static de.otto.edison.hal.Links.linkingTo;
+
+abstract class CollectionToDtoMapper {
+
+ 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/GroupDtoToGroupMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDtoToGroupMapper.java
index 459e21d62e..44c4f75eef 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDtoToGroupMapper.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupDtoToGroupMapper.java
@@ -1,13 +1,9 @@
package sonia.scm.api.v2.resources;
-import com.fasterxml.jackson.databind.JsonNode;
-import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
-import org.mapstruct.MappingTarget;
import sonia.scm.group.Group;
-import java.util.stream.Collectors;
@Mapper
public abstract class GroupDtoToGroupMapper {
@@ -16,15 +12,4 @@ public abstract class GroupDtoToGroupMapper {
@Mapping(target = "lastModified", ignore = true)
public abstract Group map(GroupDto groupDto);
- @AfterMapping
- void mapMembers(GroupDto dto, @MappingTarget Group target) {
- target.setMembers(
- dto
- .getEmbedded()
- .getItemsBy("members")
- .stream()
- .map(m -> m.getAttribute("name"))
- .map(JsonNode::asText)
- .collect(Collectors.toList()));
- }
}
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 2779ae4d5a..0ac6929689 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(BranchToBranchDtoMapper.class).to(Mappers.getMapper(BranchToBranchDtoMapper.class).getClass());
bind(UriInfoStore.class).in(ServletScopes.REQUEST);
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 61104dfffc..b4e427b9d5 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,6 +3,7 @@ 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;
@@ -165,7 +166,7 @@ public class RepositoryResource {
}
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 9453f39581..26874a9eab 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
@@ -4,6 +4,7 @@ import sonia.scm.repository.NamespaceAndName;
import javax.inject.Inject;
import javax.ws.rs.core.UriInfo;
+import java.net.URI;
class ResourceLinks {
@@ -129,15 +130,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();
}
@@ -167,6 +174,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/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 69e969f9cb..2eb9889434 100644
--- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java
+++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java
@@ -141,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());
@@ -172,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);
@@ -301,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/GroupDtoToGroupMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupDtoToGroupMapperTest.java
index a82979dc9c..64559b2419 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupDtoToGroupMapperTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/GroupDtoToGroupMapperTest.java
@@ -5,6 +5,8 @@ import org.junit.Test;
import org.mapstruct.factory.Mappers;
import sonia.scm.group.Group;
+import java.util.Arrays;
+
import static java.util.Arrays.asList;
import static org.junit.Assert.assertEquals;
@@ -21,14 +23,7 @@ public class GroupDtoToGroupMapperTest {
@Test
public void shouldMapMembers() {
GroupDto dto = new GroupDto();
-
- MemberDto member1 = new MemberDto();
- member1.getAttributes().put("name", new TextNode("member1"));
- MemberDto member2 = new MemberDto();
- member2.getAttributes().put("name", new TextNode("member2"));
-
- dto.withMembers(asList(member1, member2));
-
+ dto.setMembers(Arrays.asList("member1", "member2"));
Group group = Mappers.getMapper(GroupDtoToGroupMapper.class).map(dto);
assertEquals(2, group.getMembers().size());
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 09f8be6727..28a908566b 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,6 +13,7 @@ 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;
@@ -26,8 +27,6 @@ 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.SC_BAD_REQUEST;
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT;
@@ -37,7 +36,6 @@ 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.doThrow;
import static org.mockito.Mockito.never;
@@ -80,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");
@@ -135,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")
@@ -247,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..cfa90c9363
--- /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.junit.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.Silent.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..9adca13225
--- /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.junit.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.Silent.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 Exception {
+ 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 Exception {
+ 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 Exception {
+ 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 Exception {
+ 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..fc92d87a4c
--- /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.junit.MockitoJUnitRunner;
+import sonia.scm.repository.RepositoryType;
+
+import java.net.URI;
+
+import static org.junit.Assert.assertEquals;
+
+@RunWith(MockitoJUnitRunner.Silent.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 e24e0e659c..de657ec5cb 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
@@ -26,6 +26,9 @@ public class ResourceLinksMock {
when(resourceLinks.permissionCollection()).thenReturn(new ResourceLinks.PermissionCollectionLinks(uriInfo));
when(resourceLinks.config()).thenReturn(new ResourceLinks.ConfigLinks(uriInfo));
when(resourceLinks.branch()).thenReturn(new ResourceLinks.BranchLinks(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