merge and add single group view

This commit is contained in:
Maren Süwer
2018-07-31 15:09:45 +02:00
parent b832d744ed
commit 89618a1526
9 changed files with 375 additions and 32 deletions

View File

@@ -1,12 +1,24 @@
{ {
"group": { "group": {
"name": "Name", "name": "Name",
"description": "Description" "description": "Description",
"creationDate": "Creation Date",
"lastModified": "Last Modified",
"type": "Type",
"members": "Members"
}, },
"groups": { "groups": {
"title": "Groups", "title": "Groups",
"subtitle": "Create, read, update and delete 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": { "add-group": {
"title": "Create Group", "title": "Create Group",
"subtitle": "Create a new group" "subtitle": "Create a new group"

View File

@@ -14,7 +14,8 @@ import AddUser from "../users/containers/AddUser";
import SingleUser from "../users/containers/SingleUser"; import SingleUser from "../users/containers/SingleUser";
import Groups from "../groups/containers/Groups"; 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 = { type Props = {
authenticated?: boolean authenticated?: boolean
@@ -62,6 +63,11 @@ class Main extends React.Component<Props> {
component={Groups} component={Groups}
authenticated={authenticated} authenticated={authenticated}
/> />
<ProtectedRoute
authenticated={authenticated}
path="/group/:name"
component={SingleGroup}
/>
<ProtectedRoute <ProtectedRoute
authenticated={authenticated} authenticated={authenticated}
path="/groups/add" path="/groups/add"

View File

@@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import type { Group } from "../../types/Group"; import type { Group } from "../../types/Group";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import GroupMember from "./GroupMember";
type Props = { type Props = {
group: Group, group: Group,
@@ -9,6 +10,7 @@ type Props = {
}; };
class Details extends React.Component<Props> { class Details extends React.Component<Props> {
render() { render() {
const { group, t } = this.props; const { group, t } = this.props;
return ( return (
@@ -22,6 +24,28 @@ class Details extends React.Component<Props> {
<td>{t("group.description")}</td> <td>{t("group.description")}</td>
<td>{group.description}</td> <td>{group.description}</td>
</tr> </tr>
<tr>
<td>{t("group.creationDate")}</td>
<td>{group.creationDate}</td>
</tr>
<tr>
<td>{t("group.lastModified")}</td>
<td>{group.lastModified}</td>
</tr>
<tr>
<td>{t("group.type")}</td>
<td>{group.type}</td>
</tr>
<tr>
<td>{t("group.members")}</td>
<td>
<table><tbody>
{group.members.map((member, index) => {
return <GroupMember key={index} member={member} />;
})}
</tbody></table>
</td>
</tr>
</tbody> </tbody>
</table> </table>
); );

View File

@@ -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<Props> {
renderLink(to: string, label: string) {
return <Link to={to}>{label}</Link>;
}
render() {
const { member } = this.props;
const to = `/user/${member}`;
return (
<tr className="is-hidden-mobile">
<td>
{this.renderLink(to, member)}
</td>
</tr>
);
}
}

View File

@@ -18,7 +18,7 @@ export default class GroupRow extends React.Component<Props> {
return ( return (
<tr> <tr>
<td className="is-hidden-mobile">{this.renderLink(to, group.name)}</td> <td className="is-hidden-mobile">{this.renderLink(to, group.name)}</td>
<td>{this.renderLink(to, group.description)}</td> <td>{group.description}</td>
</tr> </tr>
); );
} }

View File

@@ -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<Props> {
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 (
<ErrorPage
title={t("single-group.error-title")}
subtitle={t("single-group.error-subtitle")}
error={error}
/>
);
}
if (!group || loading) {
return <Loading />;
}
const url = this.matchedUrl();
return (
<Page title={group.name}>
<div className="columns">
<div className="column is-three-quarters">
<Route path={url} exact component={() => <Details group={group} />} />
</div>
<div className="column">
<Navigation>
<Section label={t("single-group.navigation-label")}>
<NavLink
to={`${url}`}
label={t("single-group.information-label")}
/>
</Section>
<Section label={t("single-group.actions-label")}>
<NavLink to="/groups" label={t("single-group.back-label")} />
</Section>
</Navigation>
</div>
</div>
</Page>
);
}
}
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));

View File

@@ -37,7 +37,6 @@ const GROUPS_URL = "groups";
const CONTENT_TYPE_GROUP = "application/vnd.scmm-group+json;v=2"; const CONTENT_TYPE_GROUP = "application/vnd.scmm-group+json;v=2";
// fetch groups // fetch groups
export function fetchGroups() { export function fetchGroups() {
return fetchGroupsByLink(GROUPS_URL); 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) { export function createGroup(group: Group) {
return function(dispatch: Dispatch) { return function(dispatch: Dispatch) {
dispatch(createGroupPending()); dispatch(createGroupPending());
@@ -121,6 +168,7 @@ export function createGroupFailure(error: Error) {
}; };
} }
//reducer //reducer
function extractGroupsByNames( function extractGroupsByNames(
groups: Groups[], groups: Groups[],
@@ -179,7 +227,8 @@ function byNamesReducer(state: any = {}, action: any = {}) {
return { return {
...byNames ...byNames
}; };
case FETCH_GROUP_SUCCESS:
return reducerByName(state, action.payload.name, action.payload);
default: default:
return state; return state;
} }
@@ -254,3 +303,11 @@ export function getGroupByName(state: Object, name: string) {
return state.groups.byNames[name]; 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);
}

View File

@@ -15,6 +15,15 @@ import reducer, {
getFetchGroupsFailure, getFetchGroupsFailure,
isFetchGroupsPending, isFetchGroupsPending,
selectListAsCollection, selectListAsCollection,
fetchGroup,
FETCH_GROUP_PENDING,
FETCH_GROUP_SUCCESS,
FETCH_GROUP_FAILURE,
fetchGroupSuccess,
getFetchGroupFailure,
FETCH_GROUP,
isFetchGroupPending,
getGroupByName,
createGroup, createGroup,
CREATE_GROUP_SUCCESS, CREATE_GROUP_SUCCESS,
CREATE_GROUP_PENDING, CREATE_GROUP_PENDING,
@@ -27,22 +36,22 @@ const GROUPS_URL = "/scm/api/rest/v2/groups";
const error = new Error("You have an error!"); const error = new Error("You have an error!");
const groupZaphod = { const humanGroup = {
creationDate: "2018-07-31T08:39:07.860Z", creationDate: "2018-07-31T08:39:07.860Z",
description: "This is a group", description: "This is a group",
name: "zaphodGroup", name: "humanGroup",
type: "xml", type: "xml",
properties: {}, properties: {},
members: ["userZaphod"], members: ["userZaphod"],
_links: { _links: {
self: { self: {
href: "http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" href: "http://localhost:3000/scm/api/rest/v2/groups/humanGroup"
}, },
delete: { delete: {
href: "http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" href: "http://localhost:3000/scm/api/rest/v2/groups/humanGroup"
}, },
update: { update: {
href: "http://localhost:3000/scm/api/rest/v2/groups/zaphodGroup" href:"http://localhost:3000/scm/api/rest/v2/groups/humanGroup"
} }
}, },
_embedded: { _embedded: {
@@ -59,22 +68,22 @@ const groupZaphod = {
} }
}; };
const groupFord = { const emptyGroup = {
creationDate: "2018-07-31T08:39:07.860Z", creationDate: "2018-07-31T08:39:07.860Z",
description: "This is a group", description: "This is a group",
name: "fordGroup", name: "emptyGroup",
type: "xml", type: "xml",
properties: {}, properties: {},
members: [], members: [],
_links: { _links: {
self: { self: {
href: "http://localhost:3000/scm/api/rest/v2/groups/fordGroup" href: "http://localhost:3000/scm/api/rest/v2/groups/emptyGroup"
}, },
delete: { delete: {
href: "http://localhost:3000/scm/api/rest/v2/groups/fordGroup" href: "http://localhost:3000/scm/api/rest/v2/groups/emptyGroup"
}, },
update: { update: {
href: "http://localhost:3000/scm/api/rest/v2/groups/fordGroup" href:"http://localhost:3000/scm/api/rest/v2/groups/emptyGroup"
} }
}, },
_embedded: { _embedded: {
@@ -100,7 +109,7 @@ const responseBody = {
} }
}, },
_embedded: { _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", () => { it("should successfully create group", () => {
fetchMock.postOnce(GROUPS_URL, { fetchMock.postOnce(GROUPS_URL, {
status: 201 status: 201
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createGroup(groupZaphod)).then(() => { return store.dispatch(createGroup(humanGroup)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS); expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
@@ -167,7 +203,7 @@ describe("groups fetch()", () => {
}); });
const store = mockStore({}); const store = mockStore({});
return store.dispatch(createGroup(groupZaphod)).then(() => { return store.dispatch(createGroup(humanGroup)).then(() => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING); expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
expect(actions[1].type).toEqual(CREATE_GROUP_FAILURE); expect(actions[1].type).toEqual(CREATE_GROUP_FAILURE);
@@ -178,11 +214,13 @@ describe("groups fetch()", () => {
}); });
describe("groups reducer", () => { 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)); const newState = reducer({}, fetchGroupsSuccess(responseBody));
expect(newState.list).toEqual({ expect(newState.list).toEqual({
entries: ["zaphodGroup", "fordGroup"], entries: ["humanGroup", "emptyGroup"],
entry: { entry: {
groupCreatePermission: true, groupCreatePermission: true,
page: 0, page: 0,
@@ -192,8 +230,8 @@ describe("groups reducer", () => {
}); });
expect(newState.byNames).toEqual({ expect(newState.byNames).toEqual({
zaphodGroup: groupZaphod, humanGroup: humanGroup,
fordGroup: groupFord emptyGroup: emptyGroup
}); });
expect(newState.list.entry.groupCreatePermission).toBeTruthy(); expect(newState.list.entry.groupCreatePermission).toBeTruthy();
@@ -205,26 +243,46 @@ describe("groups reducer", () => {
expect(newState.list.entry.groupCreatePermission).toBeTruthy(); 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 = { const oldState = {
byNames: { byNames: {
fordGroup: groupFord emptyGroup: emptyGroup
} }
}; };
const newState = reducer(oldState, fetchGroupsSuccess(responseBody)); const newState = reducer(oldState, fetchGroupsSuccess(responseBody));
expect(newState.byNames["zaphodGroup"]).toBeDefined(); expect(newState.byNames["humanGroup"]).toBeDefined();
expect(newState.byNames["fordGroup"]).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)); const newState = reducer({}, fetchGroupsSuccess(responseBody));
expect(newState.list.entry.groupCreatePermission).toBeTruthy(); expect(newState.list.entry.groupCreatePermission).toBeTruthy();
expect(newState.list.entries).toEqual(["zaphodGroup", "fordGroup"]); expect(newState.list.entries).toEqual(["humanGroup", "emptyGroup"]);
expect(newState.byNames["fordGroup"]).toBeTruthy(); expect(newState.byNames["emptyGroup"]).toBeTruthy();
expect(newState.byNames["zaphodGroup"]).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", () => { describe("selector tests", () => {
@@ -311,10 +369,47 @@ describe("selector tests", () => {
expect(getFetchGroupsFailure(state)).toEqual(error); 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); 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", () => { it("should return true if create group is pending", () => {
expect(isCreateGroupPending({pending: { expect(isCreateGroupPending({pending: {
[CREATE_GROUP]: true [CREATE_GROUP]: true
@@ -336,4 +431,5 @@ describe("selector tests", () => {
it("should return undefined if creating group did not fail", () => { it("should return undefined if creating group did not fail", () => {
expect(getCreateGroupFailure({})).toBeUndefined() expect(getCreateGroupFailure({})).toBeUndefined()
}) })
}); });

View File

@@ -8,7 +8,6 @@ export type Group = {
description: string, description: string,
lastModified: string, lastModified: string,
type: string, type: string,
properties: [],
members: string[], members: string[],
_links: Links, _links: Links,
_embedded: { _embedded: {