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: {