From 3178c14af8a87bb7bfb184464ec070a280c4c0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 10:01:59 +0200 Subject: [PATCH 001/101] created new branch for groups-ui --- scm-ui/.vscode/settings.json | 2 +- scm.iml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scm-ui/.vscode/settings.json b/scm-ui/.vscode/settings.json index f5d90fdf4b..d5728921ff 100644 --- a/scm-ui/.vscode/settings.json +++ b/scm-ui/.vscode/settings.json @@ -4,7 +4,7 @@ "editor.formatOnSave": false, // Enable per-language "[javascript]": { - "editor.formatOnSave": true + "editor.formatOnSave": false }, "flow.pathToFlow": "${workspaceRoot}/node_modules/.bin/flow" } diff --git a/scm.iml b/scm.iml index 4ed0077c7f..82d1f7475d 100644 --- a/scm.iml +++ b/scm.iml @@ -8,6 +8,11 @@ + + + + + From 83c5e6746b3150f0f82dbaefae57da2dd9b68c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 10:16:18 +0200 Subject: [PATCH 002/101] added first routing and mock for groups page --- scm-ui/public/locales/en/commons.json | 3 ++- .../navigation/PrimaryNavigation.js | 5 +++++ scm-ui/src/containers/Main.js | 8 +++++++ scm-ui/src/groups/containers/Groups.js | 21 +++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 scm-ui/src/groups/containers/Groups.js diff --git a/scm-ui/public/locales/en/commons.json b/scm-ui/public/locales/en/commons.json index e6d8fb305e..1feaa80a9b 100644 --- a/scm-ui/public/locales/en/commons.json +++ b/scm-ui/public/locales/en/commons.json @@ -31,7 +31,8 @@ "primary-navigation": { "repositories": "Repositories", "users": "Users", - "logout": "Logout" + "logout": "Logout", + "groups": "Groups" }, "paginator": { "next": "Next", diff --git a/scm-ui/src/components/navigation/PrimaryNavigation.js b/scm-ui/src/components/navigation/PrimaryNavigation.js index 4fff895d90..15d6aab61c 100644 --- a/scm-ui/src/components/navigation/PrimaryNavigation.js +++ b/scm-ui/src/components/navigation/PrimaryNavigation.js @@ -23,6 +23,11 @@ class PrimaryNavigation extends React.Component { match="/(user|users)" label={t("primary-navigation.users")} /> + { path="/user/:name" component={SingleUser} /> + ); diff --git a/scm-ui/src/groups/containers/Groups.js b/scm-ui/src/groups/containers/Groups.js new file mode 100644 index 0000000000..6bfac18ebd --- /dev/null +++ b/scm-ui/src/groups/containers/Groups.js @@ -0,0 +1,21 @@ +// @flow +import React from "react"; +import type { History } from "history"; +import { connect } from "react-redux"; +import { translate } from "react-i18next"; + +type Props = { +}; + + +class Groups extends React.Component { + + + render() { + return ( + "Groups will be displayed here!" + ); + } +}; + +export default Groups; From aba7fe2b2fa1628ec1eddfb25af5f6ee9f404b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 10:17:00 +0200 Subject: [PATCH 003/101] removed unused imports --- scm-ui/src/groups/containers/Groups.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/scm-ui/src/groups/containers/Groups.js b/scm-ui/src/groups/containers/Groups.js index 6bfac18ebd..2c9969b7a3 100644 --- a/scm-ui/src/groups/containers/Groups.js +++ b/scm-ui/src/groups/containers/Groups.js @@ -1,8 +1,5 @@ // @flow import React from "react"; -import type { History } from "history"; -import { connect } from "react-redux"; -import { translate } from "react-i18next"; type Props = { }; From ea7f5e0237b26f7ca25131f158282b3f529a8cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 10:50:51 +0200 Subject: [PATCH 004/101] added missing imports --- scm-ui/src/groups/modules/groups.js | 85 +++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 scm-ui/src/groups/modules/groups.js diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js new file mode 100644 index 0000000000..92484ddba4 --- /dev/null +++ b/scm-ui/src/groups/modules/groups.js @@ -0,0 +1,85 @@ +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"; + +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"; + +// 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 + } + }; +} From 3e44a3c2810df2fe94db873db23759cfe003fb26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 10:53:44 +0200 Subject: [PATCH 005/101] added dummy tests --- scm-ui/src/groups/modules/groups.test.js | 87 ++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 scm-ui/src/groups/modules/groups.test.js 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..82c6a9d4ee --- /dev/null +++ b/scm-ui/src/groups/modules/groups.test.js @@ -0,0 +1,87 @@ +//@flow +import configureMockStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import fetchMock from "fetch-mock"; + +import {fetchGroups, + FETCH_GROUPS_PENDING, + FETCH_GROUPS_SUCCESS, + FETCH_GROUPS_FAILURE +} from "./groups" +const GROUPS_URL = "/scm/api/rest/v2/groups"; + +const groupZaphod = { +}; + +const groupFord = { +}; + +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: [groupZaphod, groupFord] + } +}; + +const response = { + headers: { "content-type": "application/json" }, + responseBody +}; + + +const error = new Error("KAPUTT"); + +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(); + }); + }); +}); From cdce20d5ea9766537b650d879617f8d000f3c3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 10:54:31 +0200 Subject: [PATCH 006/101] add dummy connector to groups --- scm-ui/src/groups/containers/Groups.js | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/scm-ui/src/groups/containers/Groups.js b/scm-ui/src/groups/containers/Groups.js index 2c9969b7a3..2405cffc34 100644 --- a/scm-ui/src/groups/containers/Groups.js +++ b/scm-ui/src/groups/containers/Groups.js @@ -1,18 +1,24 @@ -// @flow +//@flow import React from "react"; +import { connect } from "react-redux"; -type Props = { -}; - +type Props = {}; class Groups extends React.Component { - - render() { - return ( - "Groups will be displayed here!" - ); + return "groups will be displayed here"; } +} + +const mapStateToProps = state => { + return {}; }; -export default Groups; +const mapDispatchToProps = (dispatch) => { + return {}; +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Groups); From ce3adaa1b5da0f919b2b0ee9a1fe37333e40fb37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 11:30:30 +0200 Subject: [PATCH 007/101] added reducer --- scm-ui/src/groups/modules/groups.js | 71 +++++++++++++++ scm-ui/src/groups/modules/groups.test.js | 107 +++++++++++++++++++++-- 2 files changed, 173 insertions(+), 5 deletions(-) diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js index 92484ddba4..be0fc97615 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -83,3 +83,74 @@ export function fetchGroupsFailure(url: string, error: Error): Action { } }; } + + +//reducer +function extractGroupsByNames( + groups: Groups[], + 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; +} + + +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 + } + }; + + 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 + }; + + default: + return state; + } +} + +export default combineReducers({ + list: listReducer, + byNames: byNamesReducer +}); diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index 82c6a9d4ee..da0d3e97de 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -3,17 +3,66 @@ import configureMockStore from "redux-mock-store"; import thunk from "redux-thunk"; import fetchMock from "fetch-mock"; -import {fetchGroups, +import reducer, { + fetchGroups, FETCH_GROUPS_PENDING, FETCH_GROUPS_SUCCESS, - FETCH_GROUPS_FAILURE + FETCH_GROUPS_FAILURE, + fetchGroupsSuccess } from "./groups" const GROUPS_URL = "/scm/api/rest/v2/groups"; const groupZaphod = { + creationDate: "2018-07-31T08:39:07.860Z", + description: "This is a group", + name: "zaphodGroup", + type: "xml", + properties: {}, + members: ["userZaphod"], + _links: { + self: { + href: "http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" + }, + delete: { + href: "http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" + }, + update: { + href:"http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" + } + }, + _embedded: { + members: [{ + name:"userZaphod", + _links: { + self :{ + href: "http://localhost:3000/scm/api/rest/v2/users/userZaphod" + } + } + }] + } }; const groupFord = { + creationDate: "2018-07-31T08:39:07.860Z", + description: "This is a group", + name: "fordGroup", + type: "xml", + properties: {}, + members: [], + _links: { + self: { + href: "http://localhost:3000/scm/api/rest/v2/groups/fordGroup" + }, + delete: { + href: "http://localhost:3000/scm/api/rest/v2/groups/fordGroup" + }, + update: { + href:"http://localhost:3000/scm/api/rest/v2/groups/fordGroup" + } + }, + _embedded: { + members: [] + } }; const responseBody = { @@ -43,9 +92,6 @@ const response = { responseBody }; - -const error = new Error("KAPUTT"); - describe("groups fetch()", () => { const mockStore = configureMockStore([thunk]); afterEach(() => { @@ -85,3 +131,54 @@ describe("groups fetch()", () => { }); }); }); + +describe("groups reducer", () => { + + it("should update state correctly according to FETCH_USERS_SUCCESS action", () => { + const newState = reducer({}, fetchGroupsSuccess(responseBody)); + + expect(newState.list).toEqual({ + entries: ["zaphodGroup", "fordGroup"], + entry: { + groupCreatePermission: true, + page: 0, + pageTotal: 1, + _links: responseBody._links + } + }); + + expect(newState.byNames).toEqual({ + zaphodGroup: groupZaphod, + fordGroup: groupFord + }); + + 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 users", () => { + const oldState = { + byNames: { + fordGroup: groupFord + } + }; + + const newState = reducer(oldState, fetchGroupsSuccess(responseBody)); + expect(newState.byNames["zaphodGroup"]).toBeDefined(); + expect(newState.byNames["fordGroup"]).toBeDefined(); + }); + + it("should set userCreatePermission to true if create link is present", () => { + const newState = reducer({}, fetchGroupsSuccess(responseBody)); + + expect(newState.list.entry.groupCreatePermission).toBeTruthy(); + expect(newState.list.entries).toEqual(["zaphodGroup", "fordGroup"]); + expect(newState.byNames["fordGroup"]).toBeTruthy(); + expect(newState.byNames["zaphodGroup"]).toBeTruthy(); + }); +}); From 9c7c2c9d9a106ed2d3e45047ace4d9b3e8ed8d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 13:04:09 +0200 Subject: [PATCH 008/101] show group table in ui --- scm-ui/public/locales/en/groups.json | 10 ++ scm-ui/src/createReduxStore.js | 2 + scm-ui/src/groups/components/table/Details.js | 32 +++++ .../src/groups/components/table/GroupRow.js | 25 ++++ .../src/groups/components/table/GroupTable.js | 33 +++++ scm-ui/src/groups/components/table/index.js | 3 + scm-ui/src/groups/containers/Groups.js | 127 +++++++++++++++++- scm-ui/src/groups/modules/groups.js | 67 +++++++++ scm-ui/src/groups/modules/groups.test.js | 104 +++++++++++++- scm-ui/src/groups/types/Group.js | 17 +++ 10 files changed, 413 insertions(+), 7 deletions(-) create mode 100644 scm-ui/public/locales/en/groups.json create mode 100644 scm-ui/src/groups/components/table/Details.js create mode 100644 scm-ui/src/groups/components/table/GroupRow.js create mode 100644 scm-ui/src/groups/components/table/GroupTable.js create mode 100644 scm-ui/src/groups/components/table/index.js create mode 100644 scm-ui/src/groups/types/Group.js diff --git a/scm-ui/public/locales/en/groups.json b/scm-ui/public/locales/en/groups.json new file mode 100644 index 0000000000..8fe3d7e50b --- /dev/null +++ b/scm-ui/public/locales/en/groups.json @@ -0,0 +1,10 @@ +{ + "group": { + "name": "Name", + "description": "Description" + }, + "groups": { + "title": "Groups", + "subtitle": "Create, read, update and delete groups" + } +} diff --git a/scm-ui/src/createReduxStore.js b/scm-ui/src/createReduxStore.js index dd16f9cbfc..0c6e6261f7 100644 --- a/scm-ui/src/createReduxStore.js +++ b/scm-ui/src/createReduxStore.js @@ -5,6 +5,7 @@ import { createStore, compose, applyMiddleware, combineReducers } from "redux"; import { routerReducer, routerMiddleware } from "react-router-redux"; import users from "./users/modules/users"; +import groups from "./groups/modules/groups"; import auth from "./modules/auth"; import pending from "./modules/pending"; import failure from "./modules/failure"; @@ -20,6 +21,7 @@ function createReduxStore(history: BrowserHistory) { pending, failure, users, + groups, auth }); 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..30c46b78c1 --- /dev/null +++ b/scm-ui/src/groups/components/table/Details.js @@ -0,0 +1,32 @@ +//@flow +import React from "react"; +import type { Group } from "../../types/Group"; +import { translate } from "react-i18next"; +import { Checkbox } from "../../../components/forms"; + +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}
+ ); + } +} + +export default translate("groups")(Details); 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..9bbca6bb41 --- /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)} + {this.renderLink(to, 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..5078750b8e --- /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 ( + + + + + + + + + {groups.map((group, index) => { + return ; + })} + +
{t("group.name")}{t("group.description")}
+ ); + } +} + +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/Groups.js b/scm-ui/src/groups/containers/Groups.js index 2405cffc34..bf6c89efe2 100644 --- a/scm-ui/src/groups/containers/Groups.js +++ b/scm-ui/src/groups/containers/Groups.js @@ -1,24 +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"; -type Props = {}; +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) { + // backend starts paging by 0 + const statePage: number = list.page + 1; + if (page !== statePage) { + this.props.history.push(`/groups/${statePage}`); + } + } + }; + render() { - return "groups will be displayed here"; + 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 mapStateToProps = state => { - 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 {}; + return { + fetchGroupsByPage: (page: number) => { + dispatch(fetchGroupsByPage(page)); + }, + fetchGroupsByLink: (link: string) => { + dispatch(fetchGroupsByLink(link)); + } + }; }; export default connect( mapStateToProps, mapDispatchToProps -)(Groups); +)(translate("groups")(Groups)); diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js index be0fc97615..5b97fae3a2 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -5,6 +5,7 @@ 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 {Groups} from "../types/Groups"; export const FETCH_GROUPS = "scm/groups/FETCH_GROUPS"; export const FETCH_GROUPS_PENDING = `${FETCH_GROUPS}_${types.PENDING_SUFFIX}`; @@ -154,3 +155,69 @@ 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 getGroupByName(state: Object, name: string) { + if (state.groups && state.groups.byNames) { + return state.groups.byNames[name]; + } +} diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index da0d3e97de..326452e029 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -5,13 +5,23 @@ import fetchMock from "fetch-mock"; import reducer, { fetchGroups, + FETCH_GROUPS, FETCH_GROUPS_PENDING, FETCH_GROUPS_SUCCESS, FETCH_GROUPS_FAILURE, - fetchGroupsSuccess + isFetchUsersPending, + fetchGroupsSuccess, + isPermittedToCreateGroups, + getGroupsFromState, + getFetchGroupsFailure, + isFetchGroupsPending, + selectListAsCollection } from "./groups" const GROUPS_URL = "/scm/api/rest/v2/groups"; +const error = new Error("You have an error!"); + + const groupZaphod = { creationDate: "2018-07-31T08:39:07.860Z", description: "This is a group", @@ -182,3 +192,95 @@ describe("groups reducer", () => { expect(newState.byNames["zaphodGroup"]).toBeTruthy(); }); }); + + +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", () => { + expect(isPermittedToCreateGroups({})).toBe(false); + expect(isPermittedToCreateGroups({ groups: { list: { entry: {} } } })).toBe( + false + ); + expect( + isPermittedToCreateGroups({ + groups: { list: { entry: { groupCreatePermission: false } } } + }) + ).toBe(false); + }); + + it("should return 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 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 users did not fail", () => { + expect(getFetchGroupsFailure({})).toBe(undefined); + }); + +}); diff --git a/scm-ui/src/groups/types/Group.js b/scm-ui/src/groups/types/Group.js new file mode 100644 index 0000000000..f11a524dd8 --- /dev/null +++ b/scm-ui/src/groups/types/Group.js @@ -0,0 +1,17 @@ +//@flow +import type { Links } from "../../types/hal"; +import type { User } from "../../users/types/User"; + +export type Group = { + name: string, + creationDate: string, + description: string, + lastModified: string, + type: string, + properties: [], + members: string[], + _links: Links, + _embedded: { + members: User[] + } +}; From 52ce2a6de1b6e95aa68b1ab8fad08bbe45e7231b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 13:05:28 +0200 Subject: [PATCH 009/101] remove unused imports --- scm-ui/src/groups/components/table/Details.js | 1 - scm-ui/src/groups/modules/groups.test.js | 1 - 2 files changed, 2 deletions(-) diff --git a/scm-ui/src/groups/components/table/Details.js b/scm-ui/src/groups/components/table/Details.js index 30c46b78c1..ab358faf5a 100644 --- a/scm-ui/src/groups/components/table/Details.js +++ b/scm-ui/src/groups/components/table/Details.js @@ -2,7 +2,6 @@ import React from "react"; import type { Group } from "../../types/Group"; import { translate } from "react-i18next"; -import { Checkbox } from "../../../components/forms"; type Props = { group: Group, diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index 326452e029..fa671bef87 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -9,7 +9,6 @@ import reducer, { FETCH_GROUPS_PENDING, FETCH_GROUPS_SUCCESS, FETCH_GROUPS_FAILURE, - isFetchUsersPending, fetchGroupsSuccess, isPermittedToCreateGroups, getGroupsFromState, From 885650e8b8e87ac51424cbf452bd3ec7ed0eea38 Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Tue, 31 Jul 2018 13:49:46 +0200 Subject: [PATCH 010/101] Added create group functionality --- scm-ui/src/groups/modules/groups.js | 39 ++++++++++++++-- scm-ui/src/groups/modules/groups.test.js | 59 ++++++++++++++++++------ 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js index 5b97fae3a2..cea91f4b8e 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -5,7 +5,7 @@ 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 {Groups} from "../types/Groups"; +import type { Groups } from "../types/Groups"; export const FETCH_GROUPS = "scm/groups/FETCH_GROUPS"; export const FETCH_GROUPS_PENDING = `${FETCH_GROUPS}_${types.PENDING_SUFFIX}`; @@ -34,6 +34,7 @@ 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 @@ -85,6 +86,40 @@ export function fetchGroupsFailure(url: string, error: Error): Action { }; } +export function createGroup(group: Group) { + return function(dispatch: Dispatch) { + dispatch(createGroupPending()); + return apiClient + .postWithContentType(GROUPS_URL, group, CONTENT_TYPE_GROUP) + .then(() => dispatch(createGroupSuccess())) + .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 + }; +} //reducer function extractGroupsByNames( @@ -104,7 +139,6 @@ function extractGroupsByNames( return groupsByNames; } - const reducerByName = (state: any, groupname: string, newGroupState: any) => { const newGroupsByNames = { ...state, @@ -156,7 +190,6 @@ export default combineReducers({ byNames: byNamesReducer }); - // selectors const selectList = (state: Object) => { diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index fa671bef87..22d26ba3b1 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -14,13 +14,16 @@ import reducer, { getGroupsFromState, getFetchGroupsFailure, isFetchGroupsPending, - selectListAsCollection -} from "./groups" + selectListAsCollection, + createGroup, + CREATE_GROUP_SUCCESS, + CREATE_GROUP_PENDING, + CREATE_GROUP_FAILURE +} from "./groups"; const GROUPS_URL = "/scm/api/rest/v2/groups"; const error = new Error("You have an error!"); - const groupZaphod = { creationDate: "2018-07-31T08:39:07.860Z", description: "This is a group", @@ -36,18 +39,20 @@ const groupZaphod = { href: "http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" }, update: { - href:"http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" + href: "http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" } }, _embedded: { - members: [{ - name:"userZaphod", - _links: { - self :{ - href: "http://localhost:3000/scm/api/rest/v2/users/userZaphod" + members: [ + { + name: "userZaphod", + _links: { + self: { + href: "http://localhost:3000/scm/api/rest/v2/users/userZaphod" + } } } - }] + ] } }; @@ -66,7 +71,7 @@ const groupFord = { href: "http://localhost:3000/scm/api/rest/v2/groups/fordGroup" }, update: { - href:"http://localhost:3000/scm/api/rest/v2/groups/fordGroup" + href: "http://localhost:3000/scm/api/rest/v2/groups/fordGroup" } }, _embedded: { @@ -139,10 +144,37 @@ describe("groups fetch()", () => { expect(actions[1].payload).toBeDefined(); }); }); + + it("should successfully create group", () => { + fetchMock.postOnce(GROUPS_URL, { + status: 201 + }); + + const store = mockStore({}); + return store.dispatch(createGroup(groupZaphod)).then(() => { + const actions = store.getActions(); + expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); + expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS); + }); + }); + + it("should fail creating group on HTTP 500", () => { + fetchMock.postOnce(GROUPS_URL, { + status: 500 + }); + + const store = mockStore({}); + return store.dispatch(createGroup(groupZaphod)).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(); + }); + }); }); describe("groups reducer", () => { - it("should update state correctly according to FETCH_USERS_SUCCESS action", () => { const newState = reducer({}, fetchGroupsSuccess(responseBody)); @@ -192,9 +224,7 @@ describe("groups reducer", () => { }); }); - describe("selector tests", () => { - it("should return an empty object", () => { expect(selectListAsCollection({})).toEqual({}); expect(selectListAsCollection({ groups: { a: "a" } })).toEqual({}); @@ -281,5 +311,4 @@ describe("selector tests", () => { it("should return undefined when fetch users did not fail", () => { expect(getFetchGroupsFailure({})).toBe(undefined); }); - }); From 729d5f8424eacc06f75fb589bde21a2c6e9ab800 Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Tue, 31 Jul 2018 14:22:15 +0200 Subject: [PATCH 011/101] Added unit tests for group selectors --- scm-ui/src/groups/modules/groups.test.js | 27 +++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index 22d26ba3b1..69e4e0c7e7 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -18,7 +18,10 @@ import reducer, { createGroup, CREATE_GROUP_SUCCESS, CREATE_GROUP_PENDING, - CREATE_GROUP_FAILURE + CREATE_GROUP_FAILURE, + isCreateGroupPending, + CREATE_GROUP, + getCreateGroupFailure } from "./groups"; const GROUPS_URL = "/scm/api/rest/v2/groups"; @@ -311,4 +314,26 @@ describe("selector tests", () => { it("should return undefined when fetch users did not fail", () => { expect(getFetchGroupsFailure({})).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() + }) }); From b35e10df71a3375fb4ef14499ca17ca075d64f91 Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Tue, 31 Jul 2018 14:43:55 +0200 Subject: [PATCH 012/101] Bootstrapped AddGroup and GroupForm --- scm-ui/public/locales/en/groups.json | 4 ++++ scm-ui/src/containers/Main.js | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/scm-ui/public/locales/en/groups.json b/scm-ui/public/locales/en/groups.json index 8fe3d7e50b..d0de7c3fc3 100644 --- a/scm-ui/public/locales/en/groups.json +++ b/scm-ui/public/locales/en/groups.json @@ -6,5 +6,9 @@ "groups": { "title": "Groups", "subtitle": "Create, read, update and delete groups" + }, + "add-group": { + "title": "Create Group", + "subtitle": "Create a new group" } } diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index 62302d481e..7f2ab852f0 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -14,6 +14,7 @@ import AddUser from "../users/containers/AddUser"; import SingleUser from "../users/containers/SingleUser"; import Groups from "../groups/containers/Groups"; +import AddGroup from "../groups/containers/AddGroup" type Props = { authenticated?: boolean @@ -61,6 +62,11 @@ class Main extends React.Component { component={Groups} authenticated={authenticated} /> + ); From b832d744ed81cc95215d8f6869604a53a591a0f8 Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Tue, 31 Jul 2018 14:44:52 +0200 Subject: [PATCH 013/101] Bootstrapped AddGroup and GroupForm --- scm-ui/src/groups/containers/AddGroup.js | 24 ++++++++++++++++++++++++ scm-ui/src/users/containers/GroupForm.js | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 scm-ui/src/groups/containers/AddGroup.js create mode 100644 scm-ui/src/users/containers/GroupForm.js diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js new file mode 100644 index 0000000000..2a02f328c9 --- /dev/null +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -0,0 +1,24 @@ +//@flow +import React from 'react'; + +import Page from "../../components/layout/Page" +import { translate } from "react-i18next"; +import GroupForm from '../../users/containers/GroupForm'; + +export interface Props { + t: string => string +} + +export interface State { +} + +class AddGroup extends React.Component { + + render() { + const { t } = this.props; + return
+ } + +} + +export default translate("groups")(AddGroup); \ No newline at end of file diff --git a/scm-ui/src/users/containers/GroupForm.js b/scm-ui/src/users/containers/GroupForm.js new file mode 100644 index 0000000000..f65278bcf5 --- /dev/null +++ b/scm-ui/src/users/containers/GroupForm.js @@ -0,0 +1,23 @@ +//@flow +import React from 'react'; + +import InputField from "../../components/forms/InputField" +export interface Props { +} + +export interface State { +} + +class GroupForm extends React.Component { + + render() { + return ( +
+ {}} validationError={false}/> + + ) + } + +} + +export default GroupForm; \ No newline at end of file From 89618a152633a864842e52f1c5f59149ac09d725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 15:09:45 +0200 Subject: [PATCH 014/101] merge and add single group view --- scm-ui/public/locales/en/groups.json | 14 +- scm-ui/src/containers/Main.js | 8 +- scm-ui/src/groups/components/table/Details.js | 24 +++ .../groups/components/table/GroupMember.js | 26 +++ .../src/groups/components/table/GroupRow.js | 2 +- scm-ui/src/groups/containers/SingleGroup.js | 123 +++++++++++++++ scm-ui/src/groups/modules/groups.js | 61 +++++++- scm-ui/src/groups/modules/groups.test.js | 148 +++++++++++++++--- scm-ui/src/groups/types/Group.js | 1 - 9 files changed, 375 insertions(+), 32 deletions(-) create mode 100644 scm-ui/src/groups/components/table/GroupMember.js create mode 100644 scm-ui/src/groups/containers/SingleGroup.js diff --git a/scm-ui/public/locales/en/groups.json b/scm-ui/public/locales/en/groups.json index d0de7c3fc3..6caf430157 100644 --- a/scm-ui/public/locales/en/groups.json +++ b/scm-ui/public/locales/en/groups.json @@ -1,12 +1,24 @@ { "group": { "name": "Name", - "description": "Description" + "description": "Description", + "creationDate": "Creation Date", + "lastModified": "Last Modified", + "type": "Type", + "members": "Members" }, "groups": { "title": "Groups", "subtitle": "Create, read, update and delete groups" }, + "single-group": { + "error-title": "Error", + "error-subtitle": "Unknown group error", + "navigation-label": "Navigation", + "actions-label": "Actions", + "information-label": "Information", + "back-label": "Back" + }, "add-group": { "title": "Create Group", "subtitle": "Create a new group" diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index 7f2ab852f0..b7f2321a14 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -14,7 +14,8 @@ import AddUser from "../users/containers/AddUser"; import SingleUser from "../users/containers/SingleUser"; import Groups from "../groups/containers/Groups"; -import AddGroup from "../groups/containers/AddGroup" +import SingleGroup from "../groups/containers/SingleGroup"; +import AddGroup from "../groups/containers/AddGroup"; type Props = { authenticated?: boolean @@ -62,6 +63,11 @@ class Main extends React.Component { component={Groups} authenticated={authenticated} /> + { + render() { const { group, t } = this.props; return ( @@ -22,6 +24,28 @@ class Details extends React.Component { {t("group.description")} {group.description} + + {t("group.creationDate")} + {group.creationDate} + + + {t("group.lastModified")} + {group.lastModified} + + + {t("group.type")} + {group.type} + + + {t("group.members")} + + + {group.members.map((member, index) => { + return ; + })} +
+ + ); 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..342d698b24 --- /dev/null +++ b/scm-ui/src/groups/components/table/GroupMember.js @@ -0,0 +1,26 @@ +// @flow +import React from "react"; +import { Link } from "react-router-dom"; + +type Props = { + member: string +}; + +export default class GroupMember extends React.Component { + renderLink(to: string, label: string) { + return {label}; + } + + render() { + const { member } = this.props; + const to = `/user/${member}`; + return ( + + + {this.renderLink(to, member)} + + + + ); + } +} diff --git a/scm-ui/src/groups/components/table/GroupRow.js b/scm-ui/src/groups/components/table/GroupRow.js index 9bbca6bb41..f1e9fd4b2e 100644 --- a/scm-ui/src/groups/components/table/GroupRow.js +++ b/scm-ui/src/groups/components/table/GroupRow.js @@ -18,7 +18,7 @@ export default class GroupRow extends React.Component { return ( {this.renderLink(to, group.name)} - {this.renderLink(to, group.description)} + {group.description} ); } diff --git a/scm-ui/src/groups/containers/SingleGroup.js b/scm-ui/src/groups/containers/SingleGroup.js new file mode 100644 index 0000000000..c4b81395fb --- /dev/null +++ b/scm-ui/src/groups/containers/SingleGroup.js @@ -0,0 +1,123 @@ +//@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 type { Group } from "../types/Group"; +import type { History } from "history"; +import { + fetchGroup, + getGroupByName, + isFetchGroupPending, + getFetchGroupFailure, +} 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"; + +type Props = { + name: string, + group: Group, + loading: boolean, + error: Error, + + // dispatcher functions + 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; + }; + + 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); + const error = + getFetchGroupFailure(state, name); + + return { + name, + group, + loading, + error + }; +}; + +const mapDispatchToProps = dispatch => { + return { + fetchGroup: (name: string) => { + dispatch(fetchGroup(name)); + }, + }; +}; + +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 index cea91f4b8e..a3a5aa1bdb 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -37,7 +37,6 @@ const GROUPS_URL = "groups"; const CONTENT_TYPE_GROUP = "application/vnd.scmm-group+json;v=2"; // fetch groups - export function fetchGroups() { return fetchGroupsByLink(GROUPS_URL); } @@ -86,6 +85,54 @@ export function fetchGroupsFailure(url: string, error: Error): Action { }; } +//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) { return function(dispatch: Dispatch) { dispatch(createGroupPending()); @@ -121,6 +168,7 @@ export function createGroupFailure(error: Error) { }; } + //reducer function extractGroupsByNames( groups: Groups[], @@ -179,7 +227,8 @@ function byNamesReducer(state: any = {}, action: any = {}) { return { ...byNames }; - + case FETCH_GROUP_SUCCESS: + return reducerByName(state, action.payload.name, action.payload); default: return state; } @@ -254,3 +303,11 @@ export function getGroupByName(state: Object, name: string) { 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); +} diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index 69e4e0c7e7..cf3e7c0c95 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -15,6 +15,15 @@ import reducer, { 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, @@ -27,22 +36,22 @@ const GROUPS_URL = "/scm/api/rest/v2/groups"; const error = new Error("You have an error!"); -const groupZaphod = { +const humanGroup = { creationDate: "2018-07-31T08:39:07.860Z", description: "This is a group", - name: "zaphodGroup", + name: "humanGroup", type: "xml", properties: {}, members: ["userZaphod"], _links: { self: { - href: "http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" + href: "http://localhost:3000/scm/api/rest/v2/groups/humanGroup" }, delete: { - href: "http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" + href: "http://localhost:3000/scm/api/rest/v2/groups/humanGroup" }, update: { - href: "http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" + href:"http://localhost:3000/scm/api/rest/v2/groups/humanGroup" } }, _embedded: { @@ -59,22 +68,22 @@ const groupZaphod = { } }; -const groupFord = { +const emptyGroup = { creationDate: "2018-07-31T08:39:07.860Z", description: "This is a group", - name: "fordGroup", + name: "emptyGroup", type: "xml", properties: {}, members: [], _links: { self: { - href: "http://localhost:3000/scm/api/rest/v2/groups/fordGroup" + href: "http://localhost:3000/scm/api/rest/v2/groups/emptyGroup" }, delete: { - href: "http://localhost:3000/scm/api/rest/v2/groups/fordGroup" + href: "http://localhost:3000/scm/api/rest/v2/groups/emptyGroup" }, update: { - href: "http://localhost:3000/scm/api/rest/v2/groups/fordGroup" + href:"http://localhost:3000/scm/api/rest/v2/groups/emptyGroup" } }, _embedded: { @@ -100,7 +109,7 @@ const responseBody = { } }, _embedded: { - groups: [groupZaphod, groupFord] + groups: [humanGroup, emptyGroup] } }; @@ -148,13 +157,40 @@ describe("groups fetch()", () => { }); }); + it("should sucessfully fetch single group", () => { + fetchMock.getOnce(GROUPS_URL + "/humandGroup", humanGroup); + + const store = mockStore({}); + return store.dispatch(fetchGroup("humandGroup")).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 + "/humandGroup", { + status: 500 + }); + + const store = mockStore({}); + return store.dispatch(fetchGroup("humandGroup")).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(groupZaphod)).then(() => { + 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); @@ -167,7 +203,7 @@ describe("groups fetch()", () => { }); const store = mockStore({}); - return store.dispatch(createGroup(groupZaphod)).then(() => { + 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); @@ -178,11 +214,13 @@ describe("groups fetch()", () => { }); describe("groups reducer", () => { - it("should update state correctly according to FETCH_USERS_SUCCESS action", () => { + + it("should update state correctly according to FETCH_GROUPS_SUCCESS action", () => { + const newState = reducer({}, fetchGroupsSuccess(responseBody)); expect(newState.list).toEqual({ - entries: ["zaphodGroup", "fordGroup"], + entries: ["humanGroup", "emptyGroup"], entry: { groupCreatePermission: true, page: 0, @@ -192,8 +230,8 @@ describe("groups reducer", () => { }); expect(newState.byNames).toEqual({ - zaphodGroup: groupZaphod, - fordGroup: groupFord + humanGroup: humanGroup, + emptyGroup: emptyGroup }); expect(newState.list.entry.groupCreatePermission).toBeTruthy(); @@ -205,26 +243,46 @@ describe("groups reducer", () => { expect(newState.list.entry.groupCreatePermission).toBeTruthy(); }); - it("should not replace whole byNames map when fetching users", () => { + it("should not replace whole byNames map when fetching groups", () => { const oldState = { byNames: { - fordGroup: groupFord + emptyGroup: emptyGroup } }; const newState = reducer(oldState, fetchGroupsSuccess(responseBody)); - expect(newState.byNames["zaphodGroup"]).toBeDefined(); - expect(newState.byNames["fordGroup"]).toBeDefined(); + expect(newState.byNames["humanGroup"]).toBeDefined(); + expect(newState.byNames["emptyGroup"]).toBeDefined(); }); - it("should set userCreatePermission to true if create link is present", () => { + 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(["zaphodGroup", "fordGroup"]); - expect(newState.byNames["fordGroup"]).toBeTruthy(); - expect(newState.byNames["zaphodGroup"]).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"]); + }); + }); describe("selector tests", () => { @@ -311,10 +369,47 @@ describe("selector tests", () => { expect(getFetchGroupsFailure(state)).toEqual(error); }); - it("should return undefined when fetch users did not fail", () => { + 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 humandGroup is pending", () => { + const state = { + pending: { + [FETCH_GROUP + "/humandGroup"]: true + } + }; + expect(isFetchGroupPending(state, "humandGroup")).toEqual(true); + }); + + it("should return false, when fetch group humandGroup is not pending", () => { + expect(isFetchGroupPending({}, "humandGroup")).toEqual(false); + }); + + it("should return error when fetch group humandGroup did fail", () => { + const state = { + failure: { + [FETCH_GROUP + "/humandGroup"]: error + } + }; + expect(getFetchGroupFailure(state, "humandGroup")).toEqual(error); + }); + + it("should return undefined when fetch group humandGroup did not fail", () => { + expect(getFetchGroupFailure({}, "humandGroup")).toBe(undefined); + }); + it("should return true if create group is pending", () => { expect(isCreateGroupPending({pending: { [CREATE_GROUP]: true @@ -336,4 +431,5 @@ describe("selector tests", () => { it("should return undefined if creating group did not fail", () => { expect(getCreateGroupFailure({})).toBeUndefined() }) + }); diff --git a/scm-ui/src/groups/types/Group.js b/scm-ui/src/groups/types/Group.js index f11a524dd8..b300685e84 100644 --- a/scm-ui/src/groups/types/Group.js +++ b/scm-ui/src/groups/types/Group.js @@ -8,7 +8,6 @@ export type Group = { description: string, lastModified: string, type: string, - properties: [], members: string[], _links: Links, _embedded: { From f72cd23f48f468fe4b1cfeed942fc1df6b8d6c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 15:18:57 +0200 Subject: [PATCH 015/101] move groupform to correct folder --- scm-ui/src/groups/containers/AddGroup.js | 4 ++-- scm-ui/src/{users => groups}/containers/GroupForm.js | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename scm-ui/src/{users => groups}/containers/GroupForm.js (100%) diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js index 2a02f328c9..ee6166fa2e 100644 --- a/scm-ui/src/groups/containers/AddGroup.js +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -3,7 +3,7 @@ import React from 'react'; import Page from "../../components/layout/Page" import { translate } from "react-i18next"; -import GroupForm from '../../users/containers/GroupForm'; +import GroupForm from './GroupForm'; export interface Props { t: string => string @@ -21,4 +21,4 @@ class AddGroup extends React.Component { } -export default translate("groups")(AddGroup); \ No newline at end of file +export default translate("groups")(AddGroup); diff --git a/scm-ui/src/users/containers/GroupForm.js b/scm-ui/src/groups/containers/GroupForm.js similarity index 100% rename from scm-ui/src/users/containers/GroupForm.js rename to scm-ui/src/groups/containers/GroupForm.js From f90eecca89f68ce00f33897576c181303dca0e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 15:26:36 +0200 Subject: [PATCH 016/101] do not show members if no members are in that group --- scm-ui/src/groups/components/table/Details.js | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/scm-ui/src/groups/components/table/Details.js b/scm-ui/src/groups/components/table/Details.js index 1c19eb5888..3425e05427 100644 --- a/scm-ui/src/groups/components/table/Details.js +++ b/scm-ui/src/groups/components/table/Details.js @@ -36,20 +36,33 @@ class Details extends React.Component { {t("group.type")} {group.type} - - {t("group.members")} - - - {group.members.map((member, index) => { - return ; - })} -
- - + {this.renderMembers()} ); + + } + + renderMembers() { + if (this.props.group.members.length > 0) { + return ( + + {this.props.t("group.members")} + + + {this.props.group.members.map((member, index) => { + return ; + })} +
+ + + ); + } else { + return; + } + } + } export default translate("groups")(Details); From 4d4457aa3da9b943e85fa8344d046a3ba552c75b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Tue, 31 Jul 2018 16:10:50 +0200 Subject: [PATCH 017/101] only generate link for member if link exists --- scm-ui/src/groups/components/table/Details.js | 10 ++++++---- .../src/groups/components/table/GroupMember.js | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/scm-ui/src/groups/components/table/Details.js b/scm-ui/src/groups/components/table/Details.js index 3425e05427..ab8c1324ce 100644 --- a/scm-ui/src/groups/components/table/Details.js +++ b/scm-ui/src/groups/components/table/Details.js @@ -11,7 +11,9 @@ type Props = { class Details extends React.Component { - render() { + + render() { console.log(new Date('2011-04-11T10:20:30Z').toString()); + const { group, t } = this.props; return ( @@ -26,11 +28,11 @@ class Details extends React.Component { - + - + @@ -51,7 +53,7 @@ class Details extends React.Component { From f33b54f60f0172c92c6103561356898502af51c4 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 31 Jul 2018 16:32:16 +0200 Subject: [PATCH 018/101] intial import of repositroy list ui --- scm-ui/package.json | 2 + scm-ui/public/locales/en/repos.json | 6 + scm-ui/src/components/DateFromNow.js | 32 ++ .../navigation/PrimaryNavigation.js | 4 +- scm-ui/src/containers/App.css | 14 +- scm-ui/src/containers/App.js | 1 + scm-ui/src/containers/App.scss | 19 +- scm-ui/src/containers/Login.js | 2 +- scm-ui/src/containers/Main.js | 19 +- scm-ui/src/createReduxStore.js | 2 + scm-ui/src/i18n.js | 2 + .../src/repos/components/RepositoryEntry.js | 121 ++++++++ .../repos/components/RepositoryEntryLink.js | 34 +++ .../repos/components/RepositoryGroupEntry.js | 64 ++++ scm-ui/src/repos/components/RepositoryList.js | 28 ++ .../src/repos/components/groupByNamespace.js | 39 +++ .../repos/components/groupByNamespace.test.js | 74 +++++ scm-ui/src/repos/containers/Overview.js | 62 ++++ scm-ui/src/repos/modules/repos.js | 104 +++++++ scm-ui/src/repos/modules/repos.test.js | 285 ++++++++++++++++++ scm-ui/src/repos/types/Repositories.js | 25 ++ scm-ui/yarn.lock | 8 + 22 files changed, 929 insertions(+), 18 deletions(-) create mode 100644 scm-ui/public/locales/en/repos.json create mode 100644 scm-ui/src/components/DateFromNow.js create mode 100644 scm-ui/src/repos/components/RepositoryEntry.js create mode 100644 scm-ui/src/repos/components/RepositoryEntryLink.js create mode 100644 scm-ui/src/repos/components/RepositoryGroupEntry.js create mode 100644 scm-ui/src/repos/components/RepositoryList.js create mode 100644 scm-ui/src/repos/components/groupByNamespace.js create mode 100644 scm-ui/src/repos/components/groupByNamespace.test.js create mode 100644 scm-ui/src/repos/containers/Overview.js create mode 100644 scm-ui/src/repos/modules/repos.js create mode 100644 scm-ui/src/repos/modules/repos.test.js create mode 100644 scm-ui/src/repos/types/Repositories.js diff --git a/scm-ui/package.json b/scm-ui/package.json index bd182cd972..6ce2e8880e 100644 --- a/scm-ui/package.json +++ b/scm-ui/package.json @@ -6,10 +6,12 @@ "dependencies": { "bulma": "^0.7.1", "classnames": "^2.2.5", + "font-awesome": "^4.7.0", "history": "^4.7.2", "i18next": "^11.4.0", "i18next-browser-languagedetector": "^2.2.2", "i18next-fetch-backend": "^0.1.0", + "moment": "^2.22.2", "react": "^16.4.1", "react-dom": "^16.4.1", "react-i18next": "^7.9.0", diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json new file mode 100644 index 0000000000..5117f64928 --- /dev/null +++ b/scm-ui/public/locales/en/repos.json @@ -0,0 +1,6 @@ +{ + "overview": { + "title": "Repositories", + "subtitle": "Overview of available repositories" + } +} diff --git a/scm-ui/src/components/DateFromNow.js b/scm-ui/src/components/DateFromNow.js new file mode 100644 index 0000000000..b47de49a3d --- /dev/null +++ b/scm-ui/src/components/DateFromNow.js @@ -0,0 +1,32 @@ +//@flow +import React from "react"; +import moment from "moment"; +import { translate } from "react-i18next"; + +type Props = { + date?: string, + + // context props + i18n: any +}; + +class DateFromNow extends React.Component { + static format(locale: string, date?: string) { + let fromNow = ""; + if (date) { + fromNow = moment(date) + .locale(locale) + .fromNow(); + } + return fromNow; + } + + render() { + const { i18n, date } = this.props; + + const fromNow = DateFromNow.format(i18n.language, date); + return {fromNow}; + } +} + +export default translate()(DateFromNow); diff --git a/scm-ui/src/components/navigation/PrimaryNavigation.js b/scm-ui/src/components/navigation/PrimaryNavigation.js index 4fff895d90..606f5814ca 100644 --- a/scm-ui/src/components/navigation/PrimaryNavigation.js +++ b/scm-ui/src/components/navigation/PrimaryNavigation.js @@ -14,8 +14,8 @@ class PrimaryNavigation extends React.Component { - - - - - - - - diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js index d1fd7b0b62..78c679eeef 100644 --- a/scm-ui/src/groups/containers/AddGroup.js +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -6,9 +6,11 @@ import { translate } from "react-i18next"; import GroupForm from "./GroupForm"; import { connect } from "react-redux"; import { createGroup } from "../modules/groups"; +import type { Group } from "../types/Group"; export interface Props { t: string => string; + createGroup: Group => void; } export interface State {} diff --git a/scm-ui/src/groups/containers/GroupForm.js b/scm-ui/src/groups/containers/GroupForm.js index 4ef2b89035..0668b2bf2c 100644 --- a/scm-ui/src/groups/containers/GroupForm.js +++ b/scm-ui/src/groups/containers/GroupForm.js @@ -18,7 +18,18 @@ export interface State { class GroupForm extends React.Component { constructor(props) { super(props); - this.state = {}; + this.state = { + group: { + name: "", + description: "", + _embedded: { + members: [] + }, + _links: {}, + members: [], + type: "", + } + }; } onSubmit = (event: Event) => { event.preventDefault(); diff --git a/scm-ui/src/groups/types/Group.js b/scm-ui/src/groups/types/Group.js index b300685e84..92ed18b822 100644 --- a/scm-ui/src/groups/types/Group.js +++ b/scm-ui/src/groups/types/Group.js @@ -4,9 +4,7 @@ import type { User } from "../../users/types/User"; export type Group = { name: string, - creationDate: string, description: string, - lastModified: string, type: string, members: string[], _links: Links, From 6ee0e05e0cb8278bac94275724e6c9c53e963e38 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 1 Aug 2018 09:40:33 +0200 Subject: [PATCH 023/101] NamespaceAndName should be comparable to allow sort by namespaceAndName --- .../java/sonia/scm/repository/NamespaceAndName.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scm-core/src/main/java/sonia/scm/repository/NamespaceAndName.java b/scm-core/src/main/java/sonia/scm/repository/NamespaceAndName.java index 1aa6f474f0..7b71078f67 100644 --- a/scm-core/src/main/java/sonia/scm/repository/NamespaceAndName.java +++ b/scm-core/src/main/java/sonia/scm/repository/NamespaceAndName.java @@ -5,7 +5,7 @@ import com.google.common.base.Strings; import java.util.Objects; -public class NamespaceAndName { +public class NamespaceAndName implements Comparable { private final String namespace; private final String name; @@ -47,4 +47,13 @@ public class NamespaceAndName { public int hashCode() { return Objects.hash(namespace, name); } + + @Override + public int compareTo(NamespaceAndName o) { + int result = namespace.compareTo(o.namespace); + if (result == 0) { + return name.compareTo(o.name); + } + return result; + } } From ea17e536f10fc6918153598cb117499b4b585c9a Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 1 Aug 2018 09:43:49 +0200 Subject: [PATCH 024/101] change NamespaceStrategy signature to createNamespace(Repository) This change allows us to implement NamespaceStrategies, such as by type (git, hg, svn) or manual defined. The DefaultNamespaceStrategy accepts now a predefined namespace and only if no namespace was set the username of the currently logged in user is used. --- .../sonia/scm/repository/NamespaceStrategy.java | 13 ++++++++++++- .../scm/repository/DefaultNamespaceStrategy.java | 13 ++++++++++--- .../scm/repository/DefaultRepositoryManager.java | 2 +- .../repository/DefaultNamespaceStrategyTest.java | 12 ++++++++++-- .../repository/DefaultRepositoryManagerTest.java | 7 +++---- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/repository/NamespaceStrategy.java b/scm-core/src/main/java/sonia/scm/repository/NamespaceStrategy.java index f972956adf..d3529294ed 100644 --- a/scm-core/src/main/java/sonia/scm/repository/NamespaceStrategy.java +++ b/scm-core/src/main/java/sonia/scm/repository/NamespaceStrategy.java @@ -2,7 +2,18 @@ package sonia.scm.repository; import sonia.scm.plugin.ExtensionPoint; +/** + * Strategy to create a namespace for the new repository. Namespaces are used to order and identify repositories. + */ @ExtensionPoint public interface NamespaceStrategy { - String getNamespace(); + + /** + * Create new namespace for the given repository. + * + * @param repository repository + * + * @return namespace + */ + String createNamespace(Repository repository); } 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 ad6de93b0a..162f825b6a 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()); diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceStrategyTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceStrategyTest.java index dc40d1c1cb..257ae5cff1 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceStrategyTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultNamespaceStrategyTest.java @@ -17,8 +17,16 @@ public class DefaultNamespaceStrategyTest { @Test @SubjectAware(username = "trillian", password = "secret") - public void testNamespaceStrategy() { - assertEquals("trillian", namespaceStrategy.getNamespace()); + public void testNamespaceStrategyWithoutPreset() { + assertEquals("trillian", namespaceStrategy.createNamespace(new Repository())); + } + + @Test + @SubjectAware(username = "trillian", password = "secret") + public void testNamespaceStrategyWithPreset() { + Repository repository = new Repository(); + repository.setNamespace("awesome"); + assertEquals("awesome", namespaceStrategy.createNamespace(repository)); } } diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java index 02b25c0735..1f361d4766 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java @@ -40,6 +40,7 @@ import org.apache.shiro.authz.UnauthorizedException; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import sonia.scm.HandlerEventType; import sonia.scm.Manager; @@ -65,9 +66,7 @@ import java.util.HashSet; import java.util.Set; import java.util.Stack; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.hasProperty; -import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.*; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -501,7 +500,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase mockedNamespace); + when(namespaceStrategy.createNamespace(Mockito.any(Repository.class))).thenAnswer(invocation -> mockedNamespace); return new DefaultRepositoryManager(configuration, contextProvider, keyGenerator, repositoryDAO, handlerSet, createRepositoryMatcher(), namespaceStrategy); From 81ec5ae9865b679a2c2c5b7977f761fb0b1cbcb0 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 1 Aug 2018 09:44:34 +0200 Subject: [PATCH 025/101] order repositories by namespace and name --- scm-ui/src/repos/modules/repos.js | 3 ++- scm-ui/src/repos/modules/repos.test.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scm-ui/src/repos/modules/repos.js b/scm-ui/src/repos/modules/repos.js index cad3ea5d93..ccf6bf4f4e 100644 --- a/scm-ui/src/repos/modules/repos.js +++ b/scm-ui/src/repos/modules/repos.js @@ -10,12 +10,13 @@ export const FETCH_REPOS_SUCCESS = `${FETCH_REPOS}_${types.SUCCESS_SUFFIX}`; export const FETCH_REPOS_FAILURE = `${FETCH_REPOS}_${types.FAILURE_SUFFIX}`; const REPOS_URL = "repositories"; +const SORT_BY = "sortBy=namespaceAndName"; export function fetchRepos() { return function(dispatch: any) { dispatch(fetchReposPending()); return apiClient - .get(REPOS_URL) + .get(`${REPOS_URL}?${SORT_BY}`) .then(response => response.json()) .then(repositories => { dispatch(fetchReposSuccess(repositories)); diff --git a/scm-ui/src/repos/modules/repos.test.js b/scm-ui/src/repos/modules/repos.test.js index 272cf9dad6..4ba12d13fa 100644 --- a/scm-ui/src/repos/modules/repos.test.js +++ b/scm-ui/src/repos/modules/repos.test.js @@ -194,7 +194,7 @@ const repositoryCollectionWithNames: RepositoryCollection = { }; describe("repos fetch", () => { - const REPOS_URL = "/scm/api/rest/v2/repositories"; + const REPOS_URL = "/scm/api/rest/v2/repositories?sortBy=namespaceAndName"; const mockStore = configureMockStore([thunk]); afterEach(() => { From 641b3efd317d5dd9339c186f8a37f83c6af52cc5 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 1 Aug 2018 09:44:53 +0200 Subject: [PATCH 026/101] added more spacing to repository groups --- scm-ui/src/repos/components/RepositoryGroupEntry.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scm-ui/src/repos/components/RepositoryGroupEntry.js b/scm-ui/src/repos/components/RepositoryGroupEntry.js index b768577c4e..b0d145c338 100644 --- a/scm-ui/src/repos/components/RepositoryGroupEntry.js +++ b/scm-ui/src/repos/components/RepositoryGroupEntry.js @@ -8,6 +8,9 @@ import RepositoryEntry from "./RepositoryEntry"; const styles = { pointer: { cursor: "pointer" + }, + repoGroup: { + marginBottom: "1em" } }; @@ -48,7 +51,7 @@ class RepositoryGroupEntry extends React.Component { }); } return ( -
+

{group.name} From b1c65a3a3c0d8c3c30573619f6e33eb43c8c289e Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 1 Aug 2018 10:00:53 +0200 Subject: [PATCH 027/101] added loading indicator and handle failures of repository overview --- scm-ui/public/locales/en/repositories.json | 7 ----- scm-ui/src/repos/containers/Overview.js | 14 ++++++--- scm-ui/src/repos/modules/repos.js | 10 ++++++ scm-ui/src/repos/modules/repos.test.js | 31 ++++++++++++++++++- .../repositories/containers/Repositories.js | 24 -------------- 5 files changed, 50 insertions(+), 36 deletions(-) delete mode 100644 scm-ui/public/locales/en/repositories.json delete mode 100644 scm-ui/src/repositories/containers/Repositories.js diff --git a/scm-ui/public/locales/en/repositories.json b/scm-ui/public/locales/en/repositories.json deleted file mode 100644 index 4cfd96a25f..0000000000 --- a/scm-ui/public/locales/en/repositories.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "repositories": { - "title": "Repositories", - "subtitle": "Repositories will be shown here", - "body": "Coming soon ..." - } -} diff --git a/scm-ui/src/repos/containers/Overview.js b/scm-ui/src/repos/containers/Overview.js index f3498d5339..44949cb5bb 100644 --- a/scm-ui/src/repos/containers/Overview.js +++ b/scm-ui/src/repos/containers/Overview.js @@ -4,13 +4,15 @@ import React from "react"; import type { RepositoryCollection } from "../types/Repositories"; import { connect } from "react-redux"; -import { fetchRepos, getRepositoryCollection } from "../modules/repos"; +import {fetchRepos, getFetchReposFailure, getRepositoryCollection, isFetchReposPending} from "../modules/repos"; import { translate } from "react-i18next"; import { Page } from "../../components/layout"; import RepositoryList from "../components/RepositoryList"; type Props = { collection: RepositoryCollection, + loading: boolean, + error: Error, // dispatched functions fetchRepos: () => void, @@ -23,9 +25,9 @@ class Overview extends React.Component { this.props.fetchRepos(); } render() { - const { t } = this.props; + const { error, loading, t } = this.props; return ( - + {this.renderList()} ); @@ -44,8 +46,12 @@ class Overview extends React.Component { const mapStateToProps = (state, ownProps) => { const collection = getRepositoryCollection(state); + const loading = isFetchReposPending(state); + const error = getFetchReposFailure(state); return { - collection + collection, + loading, + error }; }; diff --git a/scm-ui/src/repos/modules/repos.js b/scm-ui/src/repos/modules/repos.js index ccf6bf4f4e..a70bdd7054 100644 --- a/scm-ui/src/repos/modules/repos.js +++ b/scm-ui/src/repos/modules/repos.js @@ -3,6 +3,8 @@ import { apiClient } from "../../apiclient"; import * as types from "../../modules/types"; import type { Action } from "../../types/Action"; import type { 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}`; @@ -103,3 +105,11 @@ export function getRepositoryCollection(state: Object) { }; } } + +export function isFetchReposPending(state: Object) { + return isPending(state, FETCH_REPOS); +} + +export function getFetchReposFailure(state: Object) { + return getFailure(state, FETCH_REPOS); +} diff --git a/scm-ui/src/repos/modules/repos.test.js b/scm-ui/src/repos/modules/repos.test.js index 4ba12d13fa..1031e9efe0 100644 --- a/scm-ui/src/repos/modules/repos.test.js +++ b/scm-ui/src/repos/modules/repos.test.js @@ -8,7 +8,7 @@ import reducer, { fetchRepos, FETCH_REPOS_FAILURE, fetchReposSuccess, - getRepositoryCollection + getRepositoryCollection, FETCH_REPOS, isFetchReposPending, getFetchReposFailure } from "./repos"; import type { Repository, RepositoryCollection } from "../types/Repositories"; @@ -267,6 +267,9 @@ describe("repos reducer", () => { }); describe("repos selectors", () => { + + const error = new Error("something goes wrong"); + it("should return the repositories collection", () => { const state = { repos: { @@ -282,4 +285,30 @@ describe("repos selectors", () => { 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); + }); }); 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); From bc10ce587dbcc8d498b6a0a7296869ff9a672c0b Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Wed, 1 Aug 2018 13:40:54 +0200 Subject: [PATCH 028/101] Bootstrapped validation for groups --- .../{containers => components}/GroupForm.js | 42 ++++++++++--------- .../src/groups/components/groupValidation.js | 10 +++++ scm-ui/src/groups/containers/AddGroup.js | 20 +++++---- scm-ui/src/groups/modules/groups.js | 13 +++++- scm-ui/src/groups/modules/groups.test.js | 19 +++++++++ scm-ui/src/users/containers/AddUser.js | 2 +- 6 files changed, 77 insertions(+), 29 deletions(-) rename scm-ui/src/groups/{containers => components}/GroupForm.js (62%) create mode 100644 scm-ui/src/groups/components/groupValidation.js diff --git a/scm-ui/src/groups/containers/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js similarity index 62% rename from scm-ui/src/groups/containers/GroupForm.js rename to scm-ui/src/groups/components/GroupForm.js index ac7566a32a..cef8f0b1a1 100644 --- a/scm-ui/src/groups/containers/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -5,14 +5,17 @@ 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" -export interface Props { - t: string => string; - submitForm: Group => void; +type Props = { + t: string => string, + submitForm: Group => void } -export interface State { - group: Group; +type State = { + group: Group, + nameValidationError: boolean, + descriptionValidationError: boolean } class GroupForm extends React.Component { @@ -28,21 +31,27 @@ class GroupForm extends React.Component { _links: {}, members: [], type: "", - } + }, + nameValidationError: false, + descriptionValidationError: false }; } + onSubmit = (event: Event) => { event.preventDefault(); this.props.submitForm(this.state.group); }; isValid = () => { - return true; + const group = this.state.group; + return !(this.state.nameValidationError || this.state.descriptionValidationError || group.name); } submit = (event: Event) => { event.preventDefault(); - this.props.submitForm(this.state.group) + if (this.isValid) { + this.props.submitForm(this.state.group) + } } render() { @@ -51,15 +60,15 @@ class GroupForm extends React.Component {
@@ -68,19 +77,14 @@ class GroupForm extends React.Component { handleGroupNameChange = (name: string) => { this.setState({ - group: { - ...this.state.group, - name - } + nameValidationError: !validator.isNameValid(name), + group: {...this.state.group, name} }); }; handleDescriptionChange = (description: string) => { this.setState({ - group: { - ...this.state.group, - description - } + group: {...this.state.group, description } }); }; } diff --git a/scm-ui/src/groups/components/groupValidation.js b/scm-ui/src/groups/components/groupValidation.js new file mode 100644 index 0000000000..d6d7d96f51 --- /dev/null +++ b/scm-ui/src/groups/components/groupValidation.js @@ -0,0 +1,10 @@ +// @flow + +//TODO: How should a group be validated +//TODO: Tests! + +const nameRegex = /^([A-z0-9.\-_@]|[^ ]([A-z0-9.\-_@ ]*[A-z0-9.\-_@]|[^\s])?)$/; + +export const isNameValid = (name: string) => { + return nameRegex.test(name); +}; diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js index 78c679eeef..6806478bdb 100644 --- a/scm-ui/src/groups/containers/AddGroup.js +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -3,17 +3,19 @@ import React from "react"; import Page from "../../components/layout/Page"; import { translate } from "react-i18next"; -import GroupForm from "./GroupForm"; +import GroupForm from "../components/GroupForm"; import { connect } from "react-redux"; import { createGroup } from "../modules/groups"; import type { Group } from "../types/Group"; +import type { History } from "history"; -export interface Props { - t: string => string; - createGroup: Group => void; +type Props = { + t: string => string, + createGroup: (group: Group, callback?: () => void) => void, + history: History } -export interface State {} +type State = {} class AddGroup extends React.Component { render() { @@ -27,14 +29,18 @@ class AddGroup extends React.Component { ); } + groupCreated = () => { + console.log("pushing history") + this.props.history.push("/groups") + } createGroup = (group: Group) => { - this.props.createGroup(group); + this.props.createGroup(group, this.groupCreated) }; } const mapDispatchToProps = dispatch => { return { - createGroup: (group: Group) => dispatch(createGroup(group)) + createGroup: (group: Group, callback?: () => void) => dispatch(createGroup(group, callback)) }; }; diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js index b91c8f2eb4..7f68fc9000 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -133,12 +133,16 @@ export function fetchGroupFailure(name: string, error: Error): Action { } //create group -export function createGroup(group: Group) { +export function createGroup(group: Group, callback?: () => void) { return function(dispatch: Dispatch) { dispatch(createGroupPending()); return apiClient .postWithContentType(GROUPS_URL, group, CONTENT_TYPE_GROUP) - .then(() => dispatch(createGroupSuccess())) + .then(() => { + dispatch(createGroupSuccess()) + if (callback) { + callback(); + }}) .catch(error => { dispatch( createGroupFailure( @@ -168,6 +172,11 @@ export function createGroupFailure(error: Error) { }; } +export function createGroupReset() { + return { + type: CREATE_GROUP_RESET + } +} //delete group export function deleteGroup(group: Group, callback?: () => void) { diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index 1b7c0fec0b..58354e25f0 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -205,6 +205,25 @@ describe("groups fetch()", () => { }); }); + 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 diff --git a/scm-ui/src/users/containers/AddUser.js b/scm-ui/src/users/containers/AddUser.js index a690c147e3..0aa8fc47da 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 { From 2953c805f983a1401e74e17102f6e7e0353720f7 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 1 Aug 2018 14:56:24 +0200 Subject: [PATCH 029/101] implemented paging for repository overview --- scm-ui/src/containers/Main.js | 6 ++ scm-ui/src/repos/containers/Overview.js | 55 +++++++++++++++++-- scm-ui/src/repos/modules/repos.js | 41 ++++++++++---- scm-ui/src/repos/modules/repos.test.js | 73 +++++++++++++++++++++++-- 4 files changed, 155 insertions(+), 20 deletions(-) diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index c1dcf54908..17504ef1f2 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -32,6 +32,12 @@ class Main extends React.Component { component={Overview} authenticated={authenticated} /> + void, + fetchReposByPage: number => void, + fetchReposByLink: string => void, // context props - t: string => string + t: string => string, + history: History }; class Overview extends React.Component { componentDidMount() { - this.props.fetchRepos(); + 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 ( @@ -34,21 +56,36 @@ class Overview extends React.Component { } renderList() { - const { collection } = this.props; + const { collection, fetchReposByLink } = this.props; if (collection) { 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); return { + page, collection, loading, error @@ -59,10 +96,16 @@ 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")(Overview)); +)(translate("repos")(withRouter(Overview))); diff --git a/scm-ui/src/repos/modules/repos.js b/scm-ui/src/repos/modules/repos.js index a70bdd7054..92ef8e3922 100644 --- a/scm-ui/src/repos/modules/repos.js +++ b/scm-ui/src/repos/modules/repos.js @@ -15,10 +15,32 @@ const REPOS_URL = "repositories"; 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(`${REPOS_URL}?${SORT_BY}`) + .get(url) .then(response => response.json()) .then(repositories => { dispatch(fetchReposSuccess(repositories)); @@ -76,16 +98,15 @@ export default function reducer( state: Object = {}, action: Action = { type: "UNKNOWN" } ): Object { - switch (action.type) { - case FETCH_REPOS_SUCCESS: - if (action.payload) { - return normalizeByNamespaceAndName(action.payload); - } else { - // TODO ??? - return state; - } - default: + if (action.type === FETCH_REPOS_SUCCESS) { + if (action.payload) { + return normalizeByNamespaceAndName(action.payload); + } else { + // TODO ??? return state; + } + } else { + return state; } } diff --git a/scm-ui/src/repos/modules/repos.test.js b/scm-ui/src/repos/modules/repos.test.js index 1031e9efe0..6535da187a 100644 --- a/scm-ui/src/repos/modules/repos.test.js +++ b/scm-ui/src/repos/modules/repos.test.js @@ -8,7 +8,12 @@ import reducer, { fetchRepos, FETCH_REPOS_FAILURE, fetchReposSuccess, - getRepositoryCollection, FETCH_REPOS, isFetchReposPending, getFetchReposFailure + getRepositoryCollection, + FETCH_REPOS, + isFetchReposPending, + getFetchReposFailure, + fetchReposByLink, + fetchReposByPage } from "./repos"; import type { Repository, RepositoryCollection } from "../types/Repositories"; @@ -203,7 +208,26 @@ describe("repos fetch", () => { }); it("should successfully fetch repos", () => { - fetchMock.getOnce(REPOS_URL, repositoryCollection); + const url = REPOS_URL + "&page=42"; + fetchMock.getOnce(url, 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"; + fetchMock.getOnce(url, repositoryCollection); const expectedActions = [ { type: FETCH_REPOS_PENDING }, @@ -215,7 +239,49 @@ describe("repos fetch", () => { const store = mockStore({}); - return store.dispatch(fetchRepos()).then(() => { + return store.dispatch(fetchReposByPage(43)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should successfully fetch repos from link", () => { + fetchMock.getOnce(REPOS_URL, 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); }); }); @@ -267,7 +333,6 @@ describe("repos reducer", () => { }); describe("repos selectors", () => { - const error = new Error("something goes wrong"); it("should return the repositories collection", () => { From 6dd7397d14e223ee9081d0ccd7f679388f475134 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 1 Aug 2018 14:58:52 +0200 Subject: [PATCH 030/101] fixed bug in users paging --- scm-ui/src/users/containers/Users.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scm-ui/src/users/containers/Users.js b/scm-ui/src/users/containers/Users.js index c9120ba8ec..d57f2be60f 100644 --- a/scm-ui/src/users/containers/Users.js +++ b/scm-ui/src/users/containers/Users.js @@ -50,9 +50,9 @@ class Users extends React.Component { /** * reflect page transitions in the uri */ - componentDidUpdate = (prevProps: Props) => { + componentDidUpdate() { const { page, list } = this.props; - if (list.page) { + if (list && list.page || list.page === 0) { // backend starts paging by 0 const statePage: number = list.page + 1; if (page !== statePage) { From 6719d12db9360f2526dfbc0a786bc843c946a37f Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Wed, 1 Aug 2018 15:51:54 +0200 Subject: [PATCH 031/101] Fixed route for Group paging --- scm-ui/src/containers/Main.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index b7f2321a14..2a3a1fd479 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -73,6 +73,12 @@ class Main extends React.Component { path="/groups/add" component={AddGroup} /> +

); From f426c14f70525152dfa9ec9230bb80e4f3835fa5 Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Wed, 1 Aug 2018 15:54:32 +0200 Subject: [PATCH 032/101] Refactored group validation --- scm-ui/src/groups/components/GroupForm.js | 28 +++++++++++------------ scm-ui/src/groups/containers/AddGroup.js | 18 ++++++++------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js index cef8f0b1a1..097fd92e5c 100644 --- a/scm-ui/src/groups/components/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -5,18 +5,17 @@ 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 * as validator from "./groupValidation"; type Props = { t: string => string, submitForm: Group => void -} +}; type State = { group: Group, - nameValidationError: boolean, - descriptionValidationError: boolean -} + nameValidationError: boolean +}; class GroupForm extends React.Component { constructor(props) { @@ -30,10 +29,9 @@ class GroupForm extends React.Component { }, _links: {}, members: [], - type: "", + type: "" }, - nameValidationError: false, - descriptionValidationError: false + nameValidationError: false }; } @@ -44,15 +42,15 @@ class GroupForm extends React.Component { isValid = () => { const group = this.state.group; - return !(this.state.nameValidationError || this.state.descriptionValidationError || group.name); - } + return !(this.state.nameValidationError || group.name); + }; submit = (event: Event) => { event.preventDefault(); if (this.isValid) { - this.props.submitForm(this.state.group) + this.props.submitForm(this.state.group); } - } + }; render() { const { t } = this.props; @@ -68,7 +66,7 @@ class GroupForm extends React.Component { label={t("group.description")} errorMessage="" onChange={this.handleDescriptionChange} - validationError={this.state.descriptionValidationError} + validationError={false} /> @@ -78,13 +76,13 @@ class GroupForm extends React.Component { handleGroupNameChange = (name: string) => { this.setState({ nameValidationError: !validator.isNameValid(name), - group: {...this.state.group, name} + group: { ...this.state.group, name } }); }; handleDescriptionChange = (description: string) => { this.setState({ - group: {...this.state.group, description } + group: { ...this.state.group, description } }); }; } diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js index 6806478bdb..0089e3c4ce 100644 --- a/scm-ui/src/groups/containers/AddGroup.js +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -13,9 +13,9 @@ type Props = { t: string => string, createGroup: (group: Group, callback?: () => void) => void, history: History -} +}; -type State = {} +type State = {}; class AddGroup extends React.Component { render() { @@ -30,21 +30,23 @@ class AddGroup extends React.Component { } groupCreated = () => { - console.log("pushing history") - this.props.history.push("/groups") - } + this.props.history.push("/groups"); + }; createGroup = (group: Group) => { - this.props.createGroup(group, this.groupCreated) + this.props.createGroup(group, this.groupCreated); }; } const mapDispatchToProps = dispatch => { return { - createGroup: (group: Group, callback?: () => void) => dispatch(createGroup(group, callback)) + createGroup: (group: Group, callback?: () => void) => + dispatch(createGroup(group, callback)) }; }; -const mapStateToProps = state => {}; +const mapStateToProps = state => { + return {} +}; export default connect( mapStateToProps, From aa6e1280230b66bf3c0aede768c2dbff682043eb Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Wed, 1 Aug 2018 16:28:06 +0200 Subject: [PATCH 033/101] Implemented error handling for creating groups --- scm-ui/src/groups/components/GroupForm.js | 7 +++--- scm-ui/src/groups/containers/AddGroup.js | 29 +++++++++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js index 097fd92e5c..04a22ad798 100644 --- a/scm-ui/src/groups/components/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -9,7 +9,8 @@ import * as validator from "./groupValidation"; type Props = { t: string => string, - submitForm: Group => void + submitForm: Group => void, + loading?: boolean }; type State = { @@ -53,7 +54,7 @@ class GroupForm extends React.Component { }; render() { - const { t } = this.props; + const { t, loading } = this.props; return (
{ onChange={this.handleDescriptionChange} validationError={false} /> - + ); } diff --git a/scm-ui/src/groups/containers/AddGroup.js b/scm-ui/src/groups/containers/AddGroup.js index 0089e3c4ce..c3f33279ba 100644 --- a/scm-ui/src/groups/containers/AddGroup.js +++ b/scm-ui/src/groups/containers/AddGroup.js @@ -5,25 +5,32 @@ import Page from "../../components/layout/Page"; import { translate } from "react-i18next"; import GroupForm from "../components/GroupForm"; import { connect } from "react-redux"; -import { createGroup } from "../modules/groups"; +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 + history: History, + loading?: boolean, + error?: Error, + resetForm: () => void, }; type State = {}; class AddGroup extends React.Component { + + componentDidMount() { + this.props.resetForm(); + } render() { - const { t } = this.props; + const { t, loading, error } = this.props; return ( - +
- this.createGroup(group)} /> + this.createGroup(group)} loading={loading}/>
); @@ -40,12 +47,20 @@ class AddGroup extends React.Component { const mapDispatchToProps = dispatch => { return { createGroup: (group: Group, callback?: () => void) => - dispatch(createGroup(group, callback)) + dispatch(createGroup(group, callback)), + resetForm: () => { + dispatch(createGroupReset()); + } }; }; const mapStateToProps = state => { - return {} + const loading = isCreateGroupPending(state); + const error = getCreateGroupFailure(state); + return { + loading, + error + }; }; export default connect( From ac8da1886760d5a9d10700836a31c16b059b5257 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 1 Aug 2018 18:23:16 +0200 Subject: [PATCH 034/101] start implementation repository details --- scm-ui/public/locales/en/repos.json | 4 + scm-ui/src/containers/Main.js | 7 ++ .../src/repos/components/RepositoryDetails.js | 16 +++ scm-ui/src/repos/containers/RepositoryRoot.js | 108 +++++++++++++++++ scm-ui/src/repos/modules/repos.js | 106 +++++++++++++++-- scm-ui/src/repos/modules/repos.test.js | 109 ++++++++++++++++-- scm-ui/src/users/containers/Users.js | 2 +- 7 files changed, 335 insertions(+), 17 deletions(-) create mode 100644 scm-ui/src/repos/components/RepositoryDetails.js create mode 100644 scm-ui/src/repos/containers/RepositoryRoot.js diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json index 5117f64928..428f723f37 100644 --- a/scm-ui/public/locales/en/repos.json +++ b/scm-ui/public/locales/en/repos.json @@ -2,5 +2,9 @@ "overview": { "title": "Repositories", "subtitle": "Overview of available repositories" + }, + "repository-root": { + "error-title": "Error", + "error-subtitle": "Unknown repository error" } } diff --git a/scm-ui/src/containers/Main.js b/scm-ui/src/containers/Main.js index 17504ef1f2..1c5cface29 100644 --- a/scm-ui/src/containers/Main.js +++ b/scm-ui/src/containers/Main.js @@ -12,6 +12,7 @@ import { Switch } from "react-router-dom"; import ProtectedRoute from "../components/ProtectedRoute"; import AddUser from "../users/containers/AddUser"; import SingleUser from "../users/containers/SingleUser"; +import RepositoryRoot from '../repos/containers/RepositoryRoot'; type Props = { authenticated?: boolean @@ -38,6 +39,12 @@ class Main extends React.Component { component={Overview} authenticated={authenticated} /> + { + render() { + const { repository } = this.props; + return
{repository.description}
; + } +} + +export default RepositoryDetails; diff --git a/scm-ui/src/repos/containers/RepositoryRoot.js b/scm-ui/src/repos/containers/RepositoryRoot.js new file mode 100644 index 0000000000..65de6b2fa5 --- /dev/null +++ b/scm-ui/src/repos/containers/RepositoryRoot.js @@ -0,0 +1,108 @@ +//@flow +import React from "react"; +import {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} from '../../components/navigation'; +import RepositoryDetails from '../components/RepositoryDetails'; + +type Props = { + namespace: string, + name: string, + repository: Repository, + loading: boolean, + error: Error, + + // dispatch functions + fetchRepo: (namespace: string, name: string) => void, + + // context props + t: string => string, + 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); + }; + + + 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)) + } + }; +}; + +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 index 92ef8e3922..b23cb07a15 100644 --- a/scm-ui/src/repos/modules/repos.js +++ b/scm-ui/src/repos/modules/repos.js @@ -2,7 +2,7 @@ import { apiClient } from "../../apiclient"; import * as types from "../../modules/types"; import type { Action } from "../../types/Action"; -import type { RepositoryCollection } from "../types/Repositories"; +import type {Repository, RepositoryCollection} from "../types/Repositories"; import {isPending} from "../../modules/pending"; import {getFailure} from "../../modules/failure"; @@ -11,7 +11,15 @@ 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}`; + const REPOS_URL = "repositories"; + +// fetch repos + const SORT_BY = "sortBy=namespaceAndName"; export function fetchRepos() { @@ -71,15 +79,66 @@ export function fetchReposFailure(err: Error): Action { }; } +// 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 + }; +} + // 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 = repository.namespace + "/" + repository.name; + const identifier = createIdentifier(repository); names.push(identifier); byNames[identifier] = repository; } @@ -94,18 +153,33 @@ function normalizeByNamespaceAndName( }; } +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.type === FETCH_REPOS_SUCCESS) { - if (action.payload) { + if (!action.payload) { + return state; + } + + switch (action.type) { + case FETCH_REPOS_SUCCESS: return normalizeByNamespaceAndName(action.payload); - } else { - // TODO ??? - return state; - } - } else { + case FETCH_REPO_SUCCESS: + return reducerByNames(state, action.payload); + default: return state; } } @@ -134,3 +208,17 @@ export function isFetchReposPending(state: Object) { 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); +} diff --git a/scm-ui/src/repos/modules/repos.test.js b/scm-ui/src/repos/modules/repos.test.js index 6535da187a..e214e89520 100644 --- a/scm-ui/src/repos/modules/repos.test.js +++ b/scm-ui/src/repos/modules/repos.test.js @@ -13,7 +13,16 @@ import reducer, { isFetchReposPending, getFetchReposFailure, fetchReposByLink, - fetchReposByPage + fetchReposByPage, + FETCH_REPO, + fetchRepo, + FETCH_REPO_PENDING, + FETCH_REPO_SUCCESS, + FETCH_REPO_FAILURE, + fetchRepoSuccess, + getRepository, + isFetchRepoPending, + getFetchRepoFailure } from "./repos"; import type { Repository, RepositoryCollection } from "../types/Repositories"; @@ -199,7 +208,9 @@ const repositoryCollectionWithNames: RepositoryCollection = { }; describe("repos fetch", () => { - const REPOS_URL = "/scm/api/rest/v2/repositories?sortBy=namespaceAndName"; + 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(() => { @@ -208,8 +219,7 @@ describe("repos fetch", () => { }); it("should successfully fetch repos", () => { - const url = REPOS_URL + "&page=42"; - fetchMock.getOnce(url, repositoryCollection); + fetchMock.getOnce(REPOS_URL_WITH_SORT, repositoryCollection); const expectedActions = [ { type: FETCH_REPOS_PENDING }, @@ -226,7 +236,7 @@ describe("repos fetch", () => { }); it("should successfully fetch page 42", () => { - const url = REPOS_URL + "&page=42"; + const url = REPOS_URL + "?page=42&" + SORT; fetchMock.getOnce(url, repositoryCollection); const expectedActions = [ @@ -245,7 +255,10 @@ describe("repos fetch", () => { }); it("should successfully fetch repos from link", () => { - fetchMock.getOnce(REPOS_URL, repositoryCollection); + fetchMock.getOnce( + REPOS_URL + "?" + SORT + "&page=42", + repositoryCollection + ); const expectedActions = [ { type: FETCH_REPOS_PENDING }, @@ -287,7 +300,7 @@ describe("repos fetch", () => { }); it("should dispatch FETCH_REPOS_FAILURE, it the request fails", () => { - fetchMock.getOnce(REPOS_URL, { + fetchMock.getOnce(REPOS_URL_WITH_SORT, { status: 500 }); @@ -299,6 +312,48 @@ describe("repos fetch", () => { 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"); + }); + }); }); describe("repos reducer", () => { @@ -330,6 +385,11 @@ describe("repos reducer", () => { 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); + }); }); describe("repos selectors", () => { @@ -376,4 +436,39 @@ describe("repos selectors", () => { 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); + }); }); diff --git a/scm-ui/src/users/containers/Users.js b/scm-ui/src/users/containers/Users.js index d57f2be60f..60f104a42d 100644 --- a/scm-ui/src/users/containers/Users.js +++ b/scm-ui/src/users/containers/Users.js @@ -52,7 +52,7 @@ class Users extends React.Component { */ componentDidUpdate() { const { page, list } = this.props; - if (list && list.page || list.page === 0) { + if (list && (list.page || list.page === 0)) { // backend starts paging by 0 const statePage: number = list.page + 1; if (page !== statePage) { From bdffaed268c7849fa762607dca1d4944e20c4582 Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 2 Aug 2018 08:36:12 +0200 Subject: [PATCH 035/101] Added tests for group selectors --- scm-ui/src/groups/modules/groups.test.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index 58354e25f0..664f4d1969 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -374,7 +374,6 @@ describe("groups reducer", () => { expect(newState.list.entries).toEqual(["humanGroup"]); }); - }); describe("selector tests", () => { @@ -551,4 +550,25 @@ describe("selector tests", () => { 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) + }) }); From a5d6ff3110c637b3f70205c1690ae1ef73cd3d5e Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 2 Aug 2018 08:44:13 +0200 Subject: [PATCH 036/101] Added unit test --- scm-ui/src/groups/modules/groups.test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index 664f4d1969..e8b1c1da38 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -435,9 +435,14 @@ describe("selector tests", () => { } } }; + 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: { From df11cdc3326a9496a7917456730bf14245dd74eb Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 2 Aug 2018 09:29:28 +0200 Subject: [PATCH 037/101] Added EditGroupNavLink --- scm-ui/public/locales/en/groups.json | 3 ++ .../components/navLinks/EditGroupNavLink.js | 31 +++++++++++++++++++ .../navLinks/editGroupNavLink.test.js | 29 +++++++++++++++++ scm-ui/src/groups/containers/SingleGroup.js | 2 ++ 4 files changed, 65 insertions(+) create mode 100644 scm-ui/src/groups/components/navLinks/EditGroupNavLink.js create mode 100644 scm-ui/src/groups/components/navLinks/editGroupNavLink.test.js diff --git a/scm-ui/public/locales/en/groups.json b/scm-ui/public/locales/en/groups.json index 247ba59f81..16c8dae0ec 100644 --- a/scm-ui/public/locales/en/groups.json +++ b/scm-ui/public/locales/en/groups.json @@ -26,6 +26,9 @@ "create-group-button": { "label": "Create group" }, + "edit-group-button": { + "label": "Edit" + }, "group-form": { "submit": "Submit" } 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/containers/SingleGroup.js b/scm-ui/src/groups/containers/SingleGroup.js index c4b81395fb..2319801cf8 100644 --- a/scm-ui/src/groups/containers/SingleGroup.js +++ b/scm-ui/src/groups/containers/SingleGroup.js @@ -17,6 +17,7 @@ import Loading from "../../components/Loading"; import { Navigation, Section, NavLink } from "../../components/navigation"; import ErrorPage from "../../components/ErrorPage"; import { translate } from "react-i18next"; +import EditGroupNavLink from "../components/navLinks/EditGroupNavLink"; type Props = { name: string, @@ -83,6 +84,7 @@ class SingleGroup extends React.Component { />
+
From 5cf62bf345443945a52e7bcc82bf68397f489a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Thu, 2 Aug 2018 09:53:24 +0200 Subject: [PATCH 038/101] added delete group function --- scm-ui/public/locales/en/groups.json | 9 +++ .../components/navLinks/DeleteGroupNavLink.js | 57 +++++++++++++ .../navLinks/DeleteGroupNavLink.test.js | 79 +++++++++++++++++++ .../src/groups/components/navLinks/index.js | 2 + scm-ui/src/groups/containers/SingleGroup.js | 22 +++++- 5 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js create mode 100644 scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.test.js create mode 100644 scm-ui/src/groups/components/navLinks/index.js diff --git a/scm-ui/public/locales/en/groups.json b/scm-ui/public/locales/en/groups.json index 16c8dae0ec..3484134901 100644 --- a/scm-ui/public/locales/en/groups.json +++ b/scm-ui/public/locales/en/groups.json @@ -31,5 +31,14 @@ }, "group-form": { "submit": "Submit" + }, + "delete-group-button": { + "label": "Delete", + "confirm-alert": { + "title": "Delete Group", + "message": "Do you really want to delete the group?", + "submit": "Yes", + "cancel": "No" + } } } 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/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/containers/SingleGroup.js b/scm-ui/src/groups/containers/SingleGroup.js index 2319801cf8..7f162fbdde 100644 --- a/scm-ui/src/groups/containers/SingleGroup.js +++ b/scm-ui/src/groups/containers/SingleGroup.js @@ -4,20 +4,23 @@ 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 EditGroupNavLink from "../components/navLinks/EditGroupNavLink"; type Props = { name: string, @@ -26,6 +29,7 @@ type Props = { error: Error, // dispatcher functions + deleteGroup: (group: Group, callback?: () => void) => void, fetchGroup: string => void, // context objects @@ -46,6 +50,14 @@ class SingleGroup extends React.Component { 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); }; @@ -84,6 +96,7 @@ class SingleGroup extends React.Component { />
+
@@ -99,9 +112,9 @@ const mapStateToProps = (state, ownProps) => { const name = ownProps.match.params.name; const group = getGroupByName(state, name); const loading = - isFetchGroupPending(state, name); + isFetchGroupPending(state, name) || isDeleteGroupPending(state, name); const error = - getFetchGroupFailure(state, name); + getFetchGroupFailure(state, name) || getDeleteGroupFailure(state, name); return { name, @@ -116,6 +129,9 @@ const mapDispatchToProps = dispatch => { fetchGroup: (name: string) => { dispatch(fetchGroup(name)); }, + deleteGroup: (group: Group, callback?: () => void) => { + dispatch(deleteGroup(group, callback)); + } }; }; From efda5122f717296a550c3013f54676aee0fa7e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Thu, 2 Aug 2018 10:06:13 +0200 Subject: [PATCH 039/101] refactor names of tests of group.js --- scm-ui/src/groups/modules/groups.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index e8b1c1da38..634afdb0f7 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -398,7 +398,7 @@ describe("selector tests", () => { expect(selectListAsCollection(state)).toBe(collection); }); - it("should return false", () => { + it("should return false when groupCreatePermission is false", () => { expect(isPermittedToCreateGroups({})).toBe(false); expect(isPermittedToCreateGroups({ groups: { list: { entry: {} } } })).toBe( false @@ -410,7 +410,7 @@ describe("selector tests", () => { ).toBe(false); }); - it("should return true", () => { + it("should return true when groupCreatePermission is true", () => { const state = { groups: { list: { From c4c85e6da6add78b1382449ed5be04a9ce131768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Thu, 2 Aug 2018 10:46:28 +0200 Subject: [PATCH 040/101] correct paging that /1 is shown in url --- scm-ui/src/groups/containers/Groups.js | 2 +- scm-ui/src/users/containers/Users.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scm-ui/src/groups/containers/Groups.js b/scm-ui/src/groups/containers/Groups.js index 7b7253bd98..f7a4215f84 100644 --- a/scm-ui/src/groups/containers/Groups.js +++ b/scm-ui/src/groups/containers/Groups.js @@ -51,7 +51,7 @@ class Groups extends React.Component { */ componentDidUpdate = (prevProps: Props) => { const { page, list } = this.props; - if (list.page) { + if (list.page >= 0) { // backend starts paging by 0 const statePage: number = list.page + 1; if (page !== statePage) { diff --git a/scm-ui/src/users/containers/Users.js b/scm-ui/src/users/containers/Users.js index c9120ba8ec..e1da67a5b0 100644 --- a/scm-ui/src/users/containers/Users.js +++ b/scm-ui/src/users/containers/Users.js @@ -52,7 +52,7 @@ class Users extends React.Component { */ componentDidUpdate = (prevProps: Props) => { const { page, list } = this.props; - if (list.page) { + if (list.page >= 0) { // backend starts paging by 0 const statePage: number = list.page + 1; if (page !== statePage) { From ed3b57b818e3d845361c774350b2372cca4fd66b Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 2 Aug 2018 11:38:08 +0200 Subject: [PATCH 041/101] Added group editing feature --- scm-ui/src/groups/components/GroupForm.js | 33 +++++++++--- scm-ui/src/groups/containers/EditGroup.js | 56 +++++++++++++++++++++ scm-ui/src/groups/containers/SingleGroup.js | 2 + scm-ui/src/groups/modules/groups.js | 53 +++++++++++++++++++ scm-ui/src/groups/modules/groups.test.js | 55 +++++++++++++++++++- 5 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 scm-ui/src/groups/containers/EditGroup.js diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js index 04a22ad798..0477837604 100644 --- a/scm-ui/src/groups/components/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -10,7 +10,8 @@ import * as validator from "./groupValidation"; type Props = { t: string => string, submitForm: Group => void, - loading?: boolean + loading?: boolean, + group?: Group }; type State = { @@ -36,6 +37,13 @@ class GroupForm extends React.Component { }; } + componentDidMount() { + const { group } = this.props + if (group) { + this.setState({group: {...group}}) + } + } + onSubmit = (event: Event) => { event.preventDefault(); this.props.submitForm(this.state.group); @@ -55,18 +63,27 @@ class GroupForm extends React.Component { render() { const { t, loading } = this.props; - return ( -
+ const { group } = this.state + let nameField = null; + if (!this.props.group) { + nameField = ( + label={t("group.name")} + errorMessage="group name invalid" // TODO: i18n + onChange={this.handleGroupNameChange} + value={group.name} + validationError={this.state.nameValidationError} + /> + ); + } + return ( + + {nameField} diff --git a/scm-ui/src/groups/containers/EditGroup.js b/scm-ui/src/groups/containers/EditGroup.js new file mode 100644 index 0000000000..47431a8c90 --- /dev/null +++ b/scm-ui/src/groups/containers/EditGroup.js @@ -0,0 +1,56 @@ +//@flow +import React from "react"; +import { connect } from "react-redux"; +import GroupForm from "../components/GroupForm"; +import { modifyGroup } 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, + history: History, + loading?: boolean, + error: Error +}; + +class EditGroup extends React.Component { + groupModified = (group: Group) => { + 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))} + }; +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withRouter(EditGroup)); \ No newline at end of file diff --git a/scm-ui/src/groups/containers/SingleGroup.js b/scm-ui/src/groups/containers/SingleGroup.js index 2319801cf8..fe22e25af3 100644 --- a/scm-ui/src/groups/containers/SingleGroup.js +++ b/scm-ui/src/groups/containers/SingleGroup.js @@ -18,6 +18,7 @@ import { Navigation, Section, NavLink } from "../../components/navigation"; import ErrorPage from "../../components/ErrorPage"; import { translate } from "react-i18next"; import EditGroupNavLink from "../components/navLinks/EditGroupNavLink"; +import EditGroup from "./EditGroup"; type Props = { name: string, @@ -74,6 +75,7 @@ class SingleGroup extends React.Component {
} /> + } />
diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js index 7f68fc9000..53e18656f4 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -177,6 +177,49 @@ export function createGroupReset() { type: CREATE_GROUP_RESET } } + +// modify group +export function modifyGroup(group: Group, callback?: () => void) { + return function(dispatch: Dispatch) { + dispatch(modifyGroupPending()); + return apiClient + .putWithContentType(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(): Action { + return { + type: MODIFY_GROUP_PENDING + } +} + +export function modifyGroupSuccess(group: Group): Action { + return { + type: MODIFY_GROUP_SUCCESS, + payload: group + } +} + +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) { @@ -311,6 +354,8 @@ function byNamesReducer(state: any = {}, action: any = {}) { }; 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, @@ -387,6 +432,14 @@ 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]; diff --git a/scm-ui/src/groups/modules/groups.test.js b/scm-ui/src/groups/modules/groups.test.js index e8b1c1da38..b38cbb6e57 100644 --- a/scm-ui/src/groups/modules/groups.test.js +++ b/scm-ui/src/groups/modules/groups.test.js @@ -38,7 +38,11 @@ import reducer, { DELETE_GROUP, deleteGroupSuccess, isDeleteGroupPending, - getDeleteGroupFailure + getDeleteGroupFailure, + modifyGroup, + MODIFY_GROUP_PENDING, + MODIFY_GROUP_SUCCESS, + MODIFY_GROUP_FAILURE } from "./groups"; const GROUPS_URL = "/scm/api/rest/v2/groups"; @@ -239,6 +243,55 @@ describe("groups fetch()", () => { }); }); + 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 From 0816fc3229e3f35389c17d5690925bfc5c58634d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Thu, 2 Aug 2018 11:46:51 +0200 Subject: [PATCH 042/101] do not show seperator if there are no other pages between button before and after seperator --- scm-ui/src/components/Paginator.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scm-ui/src/components/Paginator.js b/scm-ui/src/components/Paginator.js index 5b306b8240..8ec05adec9 100644 --- a/scm-ui/src/components/Paginator.js +++ b/scm-ui/src/components/Paginator.js @@ -93,8 +93,9 @@ class Paginator extends React.Component { if (page + 1 < pageTotal) { links.push(this.renderPageButton(page + 1, "next")); - links.push(this.seperator()); } + if(page+2 < pageTotal) //if there exists pages between next and last + links.push(this.seperator()); if (page < pageTotal) { links.push(this.renderLastButton()); } From 99ecc8cba205ff420d2558bc12ed9369bfce28ad Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Thu, 2 Aug 2018 11:56:35 +0200 Subject: [PATCH 043/101] implemented rest endpoint for repository types --- .../src/main/java/sonia/scm/ScmState.java | 11 +- .../scm/repository/RepositoryHandler.java | 3 + .../scm/repository/RepositoryManager.java | 3 +- .../RepositoryManagerDecorator.java | 2 +- .../main/java/sonia/scm/web/VndMediaType.java | 3 + .../scm/repository/GitRepositoryHandler.java | 5 +- .../scm/repository/HgRepositoryHandler.java | 5 +- .../scm/repository/SvnRepositoryHandler.java | 5 +- .../repository/DummyRepositoryHandler.java | 6 +- .../scm/api/v2/resources/BaseMapper.java | 5 +- .../v2/resources/CollectionToDtoMapper.java | 32 ++++ .../scm/api/v2/resources/MapperModule.java | 3 + .../RepositoryTypeCollectionResource.java | 36 +++++ .../RepositoryTypeCollectionToDtoMapper.java | 21 +++ .../api/v2/resources/RepositoryTypeDto.java | 22 +++ .../v2/resources/RepositoryTypeResource.java | 53 +++++++ .../resources/RepositoryTypeRootResource.java | 35 +++++ ...positoryTypeToRepositoryTypeDtoMapper.java | 25 +++ .../scm/api/v2/resources/ResourceLinks.java | 33 ++++ .../repository/DefaultRepositoryManager.java | 4 +- ...positoryTypeCollectionToDtoMapperTest.java | 60 ++++++++ .../RepositoryTypeRootResourceTest.java | 142 ++++++++++++++++++ ...toryTypeToRepositoryTypeDtoMapperTest.java | 42 ++++++ .../api/v2/resources/ResourceLinksMock.java | 3 + .../sonia/scm/it/IntegrationTestUtil.java | 3 +- .../test/java/sonia/scm/it/UserITCase.java | 3 +- .../DefaultRepositoryManagerPerfTest.java | 3 +- .../DefaultRepositoryManagerTest.java | 9 +- 28 files changed, 545 insertions(+), 32 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/CollectionToDtoMapper.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionResource.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionToDtoMapper.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeResource.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeRootResource.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionToDtoMapperTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeRootResourceTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java diff --git a/scm-core/src/main/java/sonia/scm/ScmState.java b/scm-core/src/main/java/sonia/scm/ScmState.java index 5610a40c08..cd36aa707c 100644 --- a/scm-core/src/main/java/sonia/scm/ScmState.java +++ b/scm-core/src/main/java/sonia/scm/ScmState.java @@ -35,6 +35,7 @@ package sonia.scm; //~--- non-JDK imports -------------------------------------------------------- +import sonia.scm.repository.RepositoryType; import sonia.scm.security.PermissionDescriptor; import sonia.scm.user.User; @@ -82,9 +83,9 @@ public final class ScmState * @since 2.0.0 */ public ScmState(String version, User user, Collection groups, - String token, Collection repositoryTypes, String defaultUserType, - ScmClientConfig clientConfig, List assignedPermission, - List availablePermissions) + String token, Collection repositoryTypes, String defaultUserType, + ScmClientConfig clientConfig, List assignedPermission, + List availablePermissions) { this.version = version; this.user = user; @@ -165,7 +166,7 @@ public final class ScmState * * @return all available repository types */ - public Collection getRepositoryTypes() + public Collection getRepositoryTypes() { return repositoryTypes; } @@ -244,7 +245,7 @@ public final class ScmState /** Field description */ @XmlElement(name = "repositoryTypes") - private Collection repositoryTypes; + private Collection repositoryTypes; /** Field description */ private User user; diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java index 8dc5f8418b..739d0d0177 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryHandler.java @@ -82,4 +82,7 @@ public interface RepositoryHandler * @since 1.15 */ public String getVersionInformation(); + + @Override + RepositoryType getType(); } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryManager.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryManager.java index 493d8f6dbb..629e0d4513 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryManager.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryManager.java @@ -35,7 +35,6 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- -import sonia.scm.Type; import sonia.scm.TypeManager; import javax.servlet.http.HttpServletRequest; @@ -100,7 +99,7 @@ public interface RepositoryManager * * @return all configured repository types */ - public Collection getConfiguredTypes(); + public Collection getConfiguredTypes(); /** * Returns the {@link Repository} associated to the request uri. diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryManagerDecorator.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryManagerDecorator.java index 6990baf7c5..b504359bc0 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryManagerDecorator.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryManagerDecorator.java @@ -103,7 +103,7 @@ public class RepositoryManagerDecorator * @return */ @Override - public Collection getConfiguredTypes() + public Collection getConfiguredTypes() { return decorated.getConfiguredTypes(); } diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index 6d20d14043..36bde974d0 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -6,6 +6,7 @@ import javax.ws.rs.core.MediaType; * Vendor media types used by SCMM. */ public class VndMediaType { + private static final String VERSION = "2"; private static final String TYPE = "application"; private static final String SUBTYPE_PREFIX = "vnd.scmm-"; @@ -18,6 +19,8 @@ public class VndMediaType { public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX; public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX; public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX; + public static final String REPOSITORY_TYPE_COLLECTION = PREFIX + "repositoryTypeCollection" + SUFFIX; + public static final String REPOSITORY_TYPE = PREFIX + "repositoryType" + SUFFIX; public static final String ME = PREFIX + "me" + SUFFIX; private VndMediaType() { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java index 2338cc3b46..87f96f850f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java @@ -41,7 +41,6 @@ import com.google.inject.Singleton; import org.eclipse.jgit.storage.file.FileRepositoryBuilder; -import sonia.scm.Type; import sonia.scm.io.FileSystem; import sonia.scm.plugin.Extension; import sonia.scm.repository.spi.GitRepositoryServiceProvider; @@ -88,7 +87,7 @@ public class GitRepositoryHandler private static final Logger logger = LoggerFactory.getLogger(GitRepositoryHandler.class); /** Field description */ - public static final Type TYPE = new RepositoryType(TYPE_NAME, + public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, TYPE_DISPLAYNAME, GitRepositoryServiceProvider.COMMANDS); @@ -167,7 +166,7 @@ public class GitRepositoryHandler * @return */ @Override - public Type getType() + public RepositoryType getType() { return TYPE; } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgRepositoryHandler.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgRepositoryHandler.java index 40987fa4da..aad546f651 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgRepositoryHandler.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/HgRepositoryHandler.java @@ -44,7 +44,6 @@ import org.slf4j.LoggerFactory; import sonia.scm.ConfigurationException; import sonia.scm.SCMContextProvider; -import sonia.scm.Type; import sonia.scm.installer.HgInstaller; import sonia.scm.installer.HgInstallerFactory; import sonia.scm.io.DirectoryFileFilter; @@ -98,7 +97,7 @@ public class HgRepositoryHandler public static final String TYPE_NAME = "hg"; /** Field description */ - public static final Type TYPE = new RepositoryType(TYPE_NAME, + public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, TYPE_DISPLAYNAME, HgRepositoryServiceProvider.COMMANDS, HgRepositoryServiceProvider.FEATURES); @@ -259,7 +258,7 @@ public class HgRepositoryHandler * @return */ @Override - public Type getType() + public RepositoryType getType() { return TYPE; } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java index dcadd5c1c6..58ada0738b 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java @@ -49,7 +49,6 @@ import org.tmatesoft.svn.core.io.SVNRepository; import org.tmatesoft.svn.core.io.SVNRepositoryFactory; import org.tmatesoft.svn.util.SVNDebugLog; -import sonia.scm.Type; import sonia.scm.io.FileSystem; import sonia.scm.logging.SVNKitLogger; import sonia.scm.plugin.Extension; @@ -87,7 +86,7 @@ public class SvnRepositoryHandler public static final String TYPE_NAME = "svn"; /** Field description */ - public static final Type TYPE = new RepositoryType(TYPE_NAME, + public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, TYPE_DISPLAYNAME, SvnRepositoryServiceProvider.COMMANDS); @@ -150,7 +149,7 @@ public class SvnRepositoryHandler * @return */ @Override - public Type getType() + public RepositoryType getType() { return TYPE; } diff --git a/scm-test/src/main/java/sonia/scm/repository/DummyRepositoryHandler.java b/scm-test/src/main/java/sonia/scm/repository/DummyRepositoryHandler.java index 870127b14e..1a1d4c413a 100644 --- a/scm-test/src/main/java/sonia/scm/repository/DummyRepositoryHandler.java +++ b/scm-test/src/main/java/sonia/scm/repository/DummyRepositoryHandler.java @@ -33,7 +33,7 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- -import sonia.scm.Type; +import com.google.common.collect.Sets; import sonia.scm.io.DefaultFileSystem; import sonia.scm.store.ConfigurationStoreFactory; @@ -54,7 +54,7 @@ public class DummyRepositoryHandler public static final String TYPE_NAME = "dummy"; - public static final Type TYPE = new Type(TYPE_NAME, TYPE_DISPLAYNAME); + public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, TYPE_DISPLAYNAME, Sets.newHashSet()); private final Set existingRepoNames = new HashSet<>(); @@ -63,7 +63,7 @@ public class DummyRepositoryHandler } @Override - public Type getType() { + public RepositoryType getType() { return TYPE; } 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/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index 8e22755fe0..eea80a077b 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 @@ -18,6 +18,9 @@ public class MapperModule extends AbstractModule { bind(RepositoryToRepositoryDtoMapper.class).to(Mappers.getMapper(RepositoryToRepositoryDtoMapper.class).getClass()); bind(RepositoryDtoToRepositoryMapper.class).to(Mappers.getMapper(RepositoryDtoToRepositoryMapper.class).getClass()); + bind(RepositoryTypeToRepositoryTypeDtoMapper.class).to(Mappers.getMapper(RepositoryTypeToRepositoryTypeDtoMapper.class).getClass()); + bind(RepositoryTypeCollectionToDtoMapper.class); + bind(UriInfoStore.class).in(ServletScopes.REQUEST); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionResource.java new file mode 100644 index 0000000000..4f9e76a939 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionResource.java @@ -0,0 +1,36 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import de.otto.edison.hal.HalRepresentation; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; + +public class RepositoryTypeCollectionResource { + + private RepositoryManager repositoryManager; + private RepositoryTypeCollectionToDtoMapper mapper; + + @Inject + public RepositoryTypeCollectionResource(RepositoryManager repositoryManager, RepositoryTypeCollectionToDtoMapper mapper) { + this.repositoryManager = repositoryManager; + this.mapper = mapper; + } + + @GET + @Path("") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.REPOSITORY_TYPE_COLLECTION) + public HalRepresentation getAll() { + return mapper.map(repositoryManager.getConfiguredTypes()); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionToDtoMapper.java new file mode 100644 index 0000000000..b9d7ff46c9 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionToDtoMapper.java @@ -0,0 +1,21 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.repository.RepositoryType; + +import javax.inject.Inject; + +public class RepositoryTypeCollectionToDtoMapper extends CollectionToDtoMapper { + + private final ResourceLinks resourceLinks; + + @Inject + public RepositoryTypeCollectionToDtoMapper(RepositoryTypeToRepositoryTypeDtoMapper mapper, ResourceLinks resourceLinks) { + super("repository-types", 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..0adc198e96 --- /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/repository-types/"; + + 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 8f6f1eaae3..f48f160d83 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 @@ -145,6 +145,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/repository/DefaultRepositoryManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java index 162f825b6a..645c3a6b72 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java @@ -300,8 +300,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/RepositoryTypeCollectionToDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionToDtoMapperTest.java new file mode 100644 index 0000000000..ecde79ae1f --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeCollectionToDtoMapperTest.java @@ -0,0 +1,60 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Link; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.runners.MockitoJUnitRunner; +import sonia.scm.repository.RepositoryType; + +import java.net.URI; +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.*; + +@RunWith(MockitoJUnitRunner.class) +public class RepositoryTypeCollectionToDtoMapperTest { + + private final URI baseUri = URI.create("https://scm-manager.org/scm/"); + + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @InjectMocks + private RepositoryTypeToRepositoryTypeDtoMapperImpl mapper; + + private List types = Lists.newArrayList( + new RepositoryType("hk", "Hitchhiker", Sets.newHashSet()), + new RepositoryType("hog", "Heart of Gold", Sets.newHashSet()) + ); + + private RepositoryTypeCollectionToDtoMapper collectionMapper; + + @Before + public void setUpEnvironment() { + collectionMapper = new RepositoryTypeCollectionToDtoMapper(mapper, resourceLinks); + } + + @Test + public void shouldHaveEmbeddedDtos() { + HalRepresentation mappedTypes = collectionMapper.map(types); + Embedded embedded = mappedTypes.getEmbedded(); + List embeddedTypes = embedded.getItemsBy("repository-types", 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/repository-types/", self.get().getHref()); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeRootResourceTest.java new file mode 100644 index 0000000000..0dfb6bf92c --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeRootResourceTest.java @@ -0,0 +1,142 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import org.jboss.resteasy.core.Dispatcher; +import org.jboss.resteasy.mock.MockDispatcherFactory; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryType; +import sonia.scm.web.VndMediaType; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; + +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; +import static javax.servlet.http.HttpServletResponse.SC_OK; +import static org.junit.Assert.*; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class RepositoryTypeRootResourceTest { + + private final Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); + + @Mock + private RepositoryManager repositoryManager; + + private final URI baseUri = URI.create("/"); + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @InjectMocks + private RepositoryTypeToRepositoryTypeDtoMapperImpl mapper; + + private List types = Lists.newArrayList( + new RepositoryType("hk", "Hitchhiker", Sets.newHashSet()), + new RepositoryType("hog", "Heart of Gold", Sets.newHashSet()) + ); + + @Before + public void prepareEnvironment() { + when(repositoryManager.getConfiguredTypes()).thenReturn(types); + + RepositoryTypeCollectionToDtoMapper collectionMapper = new RepositoryTypeCollectionToDtoMapper(mapper, resourceLinks); + RepositoryTypeCollectionResource collectionResource = new RepositoryTypeCollectionResource(repositoryManager, collectionMapper); + RepositoryTypeResource resource = new RepositoryTypeResource(repositoryManager, mapper); + RepositoryTypeRootResource rootResource = new RepositoryTypeRootResource(MockProvider.of(collectionResource), MockProvider.of(resource)); + dispatcher.getRegistry().addSingletonResource(rootResource); + } + + @Test + public void shouldHaveCollectionVndMediaType() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_OK, response.getStatus()); + String contentType = response.getOutputHeaders().getFirst("Content-Type").toString(); + assertThat(VndMediaType.REPOSITORY_TYPE_COLLECTION, equalToIgnoringCase(contentType)); + } + + @Test + public void shouldHaveCollectionSelfLink() throws URISyntaxException { + String uri = "/" + RepositoryTypeRootResource.PATH; + MockHttpRequest request = MockHttpRequest.get(uri); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_OK, response.getStatus()); + assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"" + uri + "\"}")); + } + + @Test + public void shouldHaveEmbeddedRepositoryTypes() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_OK, response.getStatus()); + assertTrue(response.getContentAsString().contains("Hitchhiker")); + assertTrue(response.getContentAsString().contains("Heart of Gold")); + } + + @Test + public void shouldHaveVndMediaType() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH + "hk"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_OK, response.getStatus()); + String contentType = response.getOutputHeaders().getFirst("Content-Type").toString(); + assertThat(VndMediaType.REPOSITORY_TYPE, equalToIgnoringCase(contentType)); + } + + @Test + public void shouldContainAttributes() throws URISyntaxException { + String uri = "/" + RepositoryTypeRootResource.PATH + "hk"; + MockHttpRequest request = MockHttpRequest.get(uri); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_OK, response.getStatus()); + assertTrue(response.getContentAsString().contains("hk")); + assertTrue(response.getContentAsString().contains("Hitchhiker")); + } + + @Test + public void shouldHaveSelfLink() throws URISyntaxException { + String uri = "/" + RepositoryTypeRootResource.PATH + "hk"; + MockHttpRequest request = MockHttpRequest.get(uri); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_OK, response.getStatus()); + assertTrue(response.getContentAsString().contains("\"self\":{\"href\":\"" + uri + "\"}")); + } + + @Test + public void shouldReturn404OnUnknownTypes() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/" + RepositoryTypeRootResource.PATH + "git"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(SC_NOT_FOUND, response.getStatus()); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java new file mode 100644 index 0000000000..9a40c253c4 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java @@ -0,0 +1,42 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.collect.Sets; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.runners.MockitoJUnitRunner; +import sonia.scm.repository.RepositoryType; + +import java.net.URI; + +import static org.junit.Assert.assertEquals; + +@RunWith(MockitoJUnitRunner.class) +public class RepositoryTypeToRepositoryTypeDtoMapperTest { + + private final URI baseUri = URI.create("https://scm-manager.org/scm/"); + + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @InjectMocks + private RepositoryTypeToRepositoryTypeDtoMapperImpl mapper; + + private RepositoryType type = new RepositoryType("hk", "Hitchhiker", Sets.newHashSet()); + + @Test + public void shouldMapSimpleProperties() { + RepositoryTypeDto dto = mapper.map(type); + assertEquals("hk", dto.getName()); + assertEquals("Hitchhiker", dto.getDisplayName()); + } + + @Test + public void shouldAppendSelfLink() { + RepositoryTypeDto dto = mapper.map(type); + assertEquals( + "https://scm-manager.org/scm/v2/repository-types/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 b35bc3820c..5c3205e8c3 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 @@ -24,6 +24,9 @@ public class ResourceLinksMock { when(resourceLinks.changesetCollection()).thenReturn(new ResourceLinks.ChangesetCollectionLinks(uriInfo)); when(resourceLinks.sourceCollection()).thenReturn(new ResourceLinks.SourceCollectionLinks(uriInfo)); when(resourceLinks.permissionCollection()).thenReturn(new ResourceLinks.PermissionCollectionLinks(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/IntegrationTestUtil.java b/scm-webapp/src/test/java/sonia/scm/it/IntegrationTestUtil.java index e12057e47a..c2f2e062f7 100644 --- a/scm-webapp/src/test/java/sonia/scm/it/IntegrationTestUtil.java +++ b/scm-webapp/src/test/java/sonia/scm/it/IntegrationTestUtil.java @@ -47,6 +47,7 @@ import sonia.scm.Type; import sonia.scm.api.rest.JSONContextResolver; import sonia.scm.api.rest.ObjectMapperProvider; import sonia.scm.repository.Person; +import sonia.scm.repository.RepositoryType; import sonia.scm.repository.client.api.ClientCommand; import sonia.scm.repository.client.api.RepositoryClient; import sonia.scm.user.User; @@ -140,7 +141,7 @@ public final class IntegrationTestUtil assertEquals("scmadmin", user.getName()); assertTrue(user.isAdmin()); - Collection types = state.getRepositoryTypes(); + Collection types = state.getRepositoryTypes(); assertNotNull(types); assertFalse(types.isEmpty()); diff --git a/scm-webapp/src/test/java/sonia/scm/it/UserITCase.java b/scm-webapp/src/test/java/sonia/scm/it/UserITCase.java index 9a749c6456..bd5ec13ab9 100644 --- a/scm-webapp/src/test/java/sonia/scm/it/UserITCase.java +++ b/scm-webapp/src/test/java/sonia/scm/it/UserITCase.java @@ -40,6 +40,7 @@ import org.junit.Test; import sonia.scm.ScmState; import sonia.scm.Type; +import sonia.scm.repository.RepositoryType; import sonia.scm.user.User; import sonia.scm.user.UserTestData; @@ -204,7 +205,7 @@ public class UserITCase extends AbstractAdminITCaseBase assertEquals("scmadmin", user.getName()); assertTrue(user.isAdmin()); - Collection types = state.getRepositoryTypes(); + Collection types = state.getRepositoryTypes(); assertNotNull(types); assertFalse(types.isEmpty()); diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java index 665c54c9e1..15c54ccb71 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java @@ -32,6 +32,7 @@ package sonia.scm.repository; import com.google.common.base.Stopwatch; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; import com.google.inject.Provider; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.AuthenticationException; @@ -109,7 +110,7 @@ public class DefaultRepositoryManagerPerfTest { */ @Before public void setUpObjectUnderTest(){ - when(repositoryHandler.getType()).thenReturn(new Type(REPOSITORY_TYPE, REPOSITORY_TYPE)); + when(repositoryHandler.getType()).thenReturn(new RepositoryType(REPOSITORY_TYPE, REPOSITORY_TYPE, Sets.newHashSet())); Set handlerSet = ImmutableSet.of(repositoryHandler); RepositoryMatcher repositoryMatcher = new RepositoryMatcher(Collections.emptySet()); NamespaceStrategy namespaceStrategy = mock(NamespaceStrategy.class); diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java index 1f361d4766..b044dfd1d7 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java @@ -36,6 +36,7 @@ import com.github.legman.Subscribe; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; import org.apache.shiro.authz.UnauthorizedException; import org.junit.Rule; import org.junit.Test; @@ -482,14 +483,14 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase Date: Thu, 2 Aug 2018 12:06:30 +0200 Subject: [PATCH 044/101] disable submit button of adding a group if name and description are invalid --- scm-ui/public/locales/en/groups.json | 4 +++- scm-ui/src/groups/components/GroupForm.js | 26 ++++++++++++++--------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/scm-ui/public/locales/en/groups.json b/scm-ui/public/locales/en/groups.json index 3484134901..4f2cdbac92 100644 --- a/scm-ui/public/locales/en/groups.json +++ b/scm-ui/public/locales/en/groups.json @@ -30,7 +30,9 @@ "label": "Edit" }, "group-form": { - "submit": "Submit" + "submit": "Submit", + "name-error": "Group name is invalid", + "description-error": "Description is invalid" }, "delete-group-button": { "label": "Delete", diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js index 0477837604..7a9df2b4c5 100644 --- a/scm-ui/src/groups/components/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -44,19 +44,25 @@ class GroupForm extends React.Component { } } - onSubmit = (event: Event) => { - event.preventDefault(); - this.props.submitForm(this.state.group); - }; + isFalsy(value) { + if (!value) { + return true; + } + return false; + } isValid = () => { const group = this.state.group; - return !(this.state.nameValidationError || group.name); + return !( + this.state.nameValidationError || + this.isFalsy(group.name) || + this.isFalsy(group.description) + ); }; submit = (event: Event) => { event.preventDefault(); - if (this.isValid) { + if (this.isValid()) { this.props.submitForm(this.state.group); } }; @@ -69,7 +75,7 @@ class GroupForm extends React.Component { nameField = ( { ); } return ( - + {nameField} - + ); } From 55f02238da785b22832e10e87e32eddfac49667b Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 2 Aug 2018 13:47:57 +0200 Subject: [PATCH 045/101] Fixed bug which caused loading state in GroupForm not to be set correctly --- scm-ui/src/groups/components/GroupForm.js | 4 ++-- scm-ui/src/groups/containers/EditGroup.js | 6 ++++-- scm-ui/src/groups/modules/groups.js | 11 +++++++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js index 0477837604..924c0bdee3 100644 --- a/scm-ui/src/groups/components/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -63,7 +63,7 @@ class GroupForm extends React.Component { render() { const { t, loading } = this.props; - const { group } = this.state + const group = this.state.group let nameField = null; if (!this.props.group) { nameField = ( @@ -76,7 +76,7 @@ class GroupForm extends React.Component { /> ); } - return ( + return (
{nameField} { - groupModified = (group: Group) => { + groupModified = (group: Group) => () => { this.props.history.push(`/group/${group.name}`) } @@ -46,7 +46,9 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = (dispatch) => { return { - modifyGroup: (group: Group, callback?: () => void) => {dispatch(modifyGroup(group, callback))} + modifyGroup: (group: Group, callback?: () => void) => { + dispatch(modifyGroup(group, callback)) + } }; }; diff --git a/scm-ui/src/groups/modules/groups.js b/scm-ui/src/groups/modules/groups.js index 53e18656f4..b9fc3bef80 100644 --- a/scm-ui/src/groups/modules/groups.js +++ b/scm-ui/src/groups/modules/groups.js @@ -181,7 +181,7 @@ export function createGroupReset() { // modify group export function modifyGroup(group: Group, callback?: () => void) { return function(dispatch: Dispatch) { - dispatch(modifyGroupPending()); + dispatch(modifyGroupPending(group)); return apiClient .putWithContentType(group._links.update.href, group, CONTENT_TYPE_GROUP) .then(() => { @@ -196,16 +196,19 @@ export function modifyGroup(group: Group, callback?: () => void) { }; } -export function modifyGroupPending(): Action { +export function modifyGroupPending(group: Group): Action { return { - type: MODIFY_GROUP_PENDING + type: MODIFY_GROUP_PENDING, + payload: group, + itemId: group.name } } export function modifyGroupSuccess(group: Group): Action { return { type: MODIFY_GROUP_SUCCESS, - payload: group + payload: group, + itemId: group.name } } From 80d4130e03ff373f398306c61dedc66ac5c57ea2 Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 2 Aug 2018 16:03:17 +0200 Subject: [PATCH 046/101] Group Resource now accepts a list of member's names instead of full user objects --- .../api/v2/resources/GroupDtoToGroupMapper.java | 15 --------------- .../v2/resources/GroupDtoToGroupMapperTest.java | 11 +++-------- 2 files changed, 3 insertions(+), 23 deletions(-) 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/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()); From 7e2abee3966e6fb7b39e1539f440cabde03609be Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 2 Aug 2018 16:08:43 +0200 Subject: [PATCH 047/101] Fixed test data --- .../test/resources/sonia/scm/api/v2/group-test-create.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/group-test-create.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/group-test-create.json index 671bbeec4f..b594b722d3 100644 --- a/scm-webapp/src/test/resources/sonia/scm/api/v2/group-test-create.json +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/group-test-create.json @@ -2,14 +2,9 @@ "description": "Tolle Gruppe", "name": "dev", "type": "developers", + "members": ["user1", "user2"], "_embedded": { "members": [ - { - "name": "user1" - }, - { - "name": "user2" - } ] } } From da115f0caa19bf456bca9488205f6630e0a8e71d Mon Sep 17 00:00:00 2001 From: Philipp Czora Date: Thu, 2 Aug 2018 16:09:25 +0200 Subject: [PATCH 048/101] Members can now be added to/removed from groups --- scm-ui/src/groups/components/GroupForm.js | 57 +++++++++++++++++++++-- scm-ui/src/groups/containers/EditGroup.js | 7 ++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js index fca001a65f..00904112bc 100644 --- a/scm-ui/src/groups/components/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -2,7 +2,7 @@ import React from "react"; import InputField from "../../components/forms/InputField"; -import { SubmitButton } from "../../components/buttons"; +import { SubmitButton, Button } from "../../components/buttons"; import { translate } from "react-i18next"; import type { Group } from "../types/Group"; import * as validator from "./groupValidation"; @@ -16,6 +16,7 @@ type Props = { type State = { group: Group, + userToAdd: string, nameValidationError: boolean }; @@ -33,7 +34,8 @@ class GroupForm extends React.Component { members: [], type: "" }, - nameValidationError: false + nameValidationError: false, + userToAdd: "" }; } @@ -67,7 +69,7 @@ class GroupForm extends React.Component { } }; - render() { +render() { const { t, loading } = this.props; const group = this.state.group let nameField = null; @@ -93,11 +95,53 @@ class GroupForm extends React.Component { value={group.description} validationError={false} /> + +
{t("group.creationDate")}{group.creationDate}{new Date(group.creationDate).toString()}
{t("group.lastModified")}{group.lastModified}{new Date(group.lastModified).toString()}
{t("group.type")}{this.props.t("group.members")} - {this.props.group.members.map((member, index) => { + {this.props.group._embedded.members.map((member, index) => { return ; })}
diff --git a/scm-ui/src/groups/components/table/GroupMember.js b/scm-ui/src/groups/components/table/GroupMember.js index 342d698b24..d372a5075f 100644 --- a/scm-ui/src/groups/components/table/GroupMember.js +++ b/scm-ui/src/groups/components/table/GroupMember.js @@ -1,9 +1,10 @@ // @flow import React from "react"; import { Link } from "react-router-dom"; +import type {User} from "../../../users/types/User"; type Props = { - member: string + member: User }; export default class GroupMember extends React.Component { @@ -11,13 +12,22 @@ export default class GroupMember extends React.Component { return {label}; } + showName(to: any, member:User) { + if(member._links.self){ + return this.renderLink(to, member.name); + } + else { + return member.name + } + } + render() { const { member } = this.props; - const to = `/user/${member}`; + const to = `/user/${member.name}`; return (
- {this.renderLink(to, member)} + {this.showName(to, member)}
{t("group.description")} {group.description}
{t("group.creationDate")}{new Date(group.creationDate).toString()}
{t("group.lastModified")}{new Date(group.lastModified).toString()}
{t("group.type")} {group.type}
+ + {this.state.group.members.map((user, index) => { + return + + + + })} + +
{user}
+ + +