merge with branch feature/ui-for-scm2_groups

This commit is contained in:
Sebastian Sdorra
2018-08-08 14:59:39 +02:00
45 changed files with 2375 additions and 47 deletions

View File

@@ -4,7 +4,7 @@
"editor.formatOnSave": false,
// Enable per-language
"[javascript]": {
"editor.formatOnSave": true
"editor.formatOnSave": false
},
"flow.pathToFlow": "${workspaceRoot}/node_modules/.bin/flow"
}

View File

@@ -31,7 +31,8 @@
"primary-navigation": {
"repositories": "Repositories",
"users": "Users",
"logout": "Logout"
"logout": "Logout",
"groups": "Groups"
},
"paginator": {
"next": "Next",

View File

@@ -0,0 +1,56 @@
{
"group": {
"name": "Name",
"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"
},
"create-group-button": {
"label": "Create"
},
"edit-group-button": {
"label": "Edit"
},
"add-member-button": {
"label": "Add member"
},
"remove-member-button": {
"label": "Remove member"
},
"add-member-textfield": {
"label": "Add member",
"error": "Invalid member name"
},
"group-form": {
"submit": "Submit",
"name-error": "Group name is invalid",
"description-error": "Description is invalid"
},
"delete-group-button": {
"label": "Delete",
"confirm-alert": {
"title": "Delete Group",
"message": "Do you really want to delete the group?",
"submit": "Yes",
"cancel": "No"
}
}
}

View File

@@ -93,8 +93,9 @@ class Paginator extends React.Component<Props> {
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());
}

View File

@@ -0,0 +1,11 @@
//@flow
import React from "react";
import Button, { type ButtonProps } from "./Button";
class AddButton extends React.Component<ButtonProps> {
render() {
return <Button color="default" {...this.props} />;
}
}
export default AddButton;

View File

@@ -7,7 +7,7 @@ export type ButtonProps = {
label: string,
loading?: boolean,
disabled?: boolean,
action?: () => void,
action?: (event: Event) => void,
link?: string,
fullWidth?: boolean,
className?: string,
@@ -15,12 +15,14 @@ export type ButtonProps = {
};
type Props = ButtonProps & {
type: string
type: string,
color: string
};
class Button extends React.Component<Props> {
static defaultProps = {
type: "default"
type: "button",
color: "default"
};
renderButton = () => {
@@ -29,6 +31,7 @@ class Button extends React.Component<Props> {
loading,
disabled,
type,
color,
action,
fullWidth,
className
@@ -37,11 +40,12 @@ class Button extends React.Component<Props> {
const fullWidthClass = fullWidth ? "is-fullwidth" : "";
return (
<button
type={type}
disabled={disabled}
onClick={action ? action : () => {}}
onClick={action ? action : (event: Event) => {}}
className={classNames(
"button",
"is-" + type,
"is-" + color,
loadingClass,
fullWidthClass,
className

View File

@@ -1,7 +1,7 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import Button, { type ButtonProps } from "./Button";
import AddButton, { type ButtonProps } from "./Button";
import classNames from "classnames";
const styles = {
@@ -15,7 +15,7 @@ class CreateButton extends React.Component<ButtonProps> {
const { classes } = this.props;
return (
<div className={classNames("is-pulled-right", classes.spacing)}>
<Button type="default" {...this.props} />
<AddButton {...this.props} />;
</div>
);
}

View File

@@ -4,7 +4,7 @@ import Button, { type ButtonProps } from "./Button";
class DeleteButton extends React.Component<ButtonProps> {
render() {
return <Button type="warning" {...this.props} />;
return <Button color="warning" {...this.props} />;
}
}

View File

@@ -4,7 +4,7 @@ import Button, { type ButtonProps } from "./Button";
class EditButton extends React.Component<ButtonProps> {
render() {
return <Button type="default" {...this.props} />;
return <Button color="default" {...this.props} />;
}
}

View File

@@ -4,7 +4,7 @@ import Button, { type ButtonProps } from "./Button";
class SubmitButton extends React.Component<ButtonProps> {
render() {
return <Button type="primary" {...this.props} />;
return <Button type="submit" color="primary" {...this.props} />;
}
}

View File

@@ -1,5 +1,6 @@
export { default as CreateButton } from "./CreateButton";
export { default as Button } from "./Button";
export { default as AddButton } from "./AddButton";
export { default as CreateButton } from "./CreateButton";
export { default as DeleteButton } from "./DeleteButton";
export { default as EditButton } from "./EditButton";
export { default as SubmitButton } from "./SubmitButton";

View File

@@ -9,6 +9,7 @@ type Props = {
type?: string,
autofocus?: boolean,
onChange: string => void,
onReturnPressed?: () => void,
validationError: boolean,
errorMessage: string
};
@@ -39,6 +40,17 @@ class InputField extends React.Component<Props> {
return "";
};
handleKeyPress = (event: SyntheticKeyboardEvent<HTMLInputElement>) => {
const onReturnPressed = this.props.onReturnPressed;
if (!onReturnPressed) {
return
}
if (event.key === "Enter") {
event.preventDefault();
onReturnPressed();
}
}
render() {
const { type, placeholder, value, validationError, errorMessage } = this.props;
const errorView = validationError ? "is-danger" : "";
@@ -59,6 +71,7 @@ class InputField extends React.Component<Props> {
placeholder={placeholder}
value={value}
onChange={this.handleInput}
onKeyPress={this.handleKeyPress}
/>
</div>
{helper}

View File

@@ -23,6 +23,11 @@ class PrimaryNavigation extends React.Component<Props> {
match="/(user|users)"
label={t("primary-navigation.users")}
/>
<PrimaryNavigationLink
to="/groups"
match="/(group|groups)"
label={t("primary-navigation.groups")}
/>
<PrimaryNavigationLink
to="/logout"
label={t("primary-navigation.logout")}

View File

@@ -1,3 +1,4 @@
// @flow
import * as validator from "./validation";
describe("test name validation", () => {
@@ -13,7 +14,12 @@ describe("test name validation", () => {
"t ",
" t",
" t ",
""
"",
" invalid_name",
"another%one",
"!!!",
"!_!"
];
for (let name of invalidNames) {
expect(validator.isNameValid(name)).toBe(false);
@@ -31,7 +37,13 @@ describe("test name validation", () => {
"test@scm-manager.de",
"test 123",
"tt",
"t"
"t",
"valid_name",
"another1",
"stillValid",
"this.one_as-well",
"and@this"
];
for (let name of validNames) {
expect(validator.isNameValid(name)).toBe(true);

View File

@@ -15,6 +15,10 @@ import SingleUser from "../users/containers/SingleUser";
import RepositoryRoot from "../repos/containers/RepositoryRoot";
import Create from "../repos/containers/Create";
import Groups from "../groups/containers/Groups";
import SingleGroup from "../groups/containers/SingleGroup";
import AddGroup from "../groups/containers/AddGroup";
type Props = {
authenticated?: boolean
};
@@ -73,6 +77,28 @@ class Main extends React.Component<Props> {
path="/user/:name"
component={SingleUser}
/>
<ProtectedRoute
exact
path="/groups"
component={Groups}
authenticated={authenticated}
/>
<ProtectedRoute
authenticated={authenticated}
path="/group/:name"
component={SingleGroup}
/>
<ProtectedRoute
authenticated={authenticated}
path="/groups/add"
component={AddGroup}
/>
<ProtectedRoute
exact
path="/groups/:page"
component={Groups}
authenticated={authenticated}
/>
</Switch>
</div>
);

View File

@@ -7,6 +7,7 @@ import { routerReducer, routerMiddleware } from "react-router-redux";
import users from "./users/modules/users";
import repos from "./repos/modules/repos";
import repositoryTypes from "./repos/modules/repositoryTypes";
import groups from "./groups/modules/groups";
import auth from "./modules/auth";
import pending from "./modules/pending";
import failure from "./modules/failure";
@@ -24,6 +25,7 @@ function createReduxStore(history: BrowserHistory) {
users,
repos,
repositoryTypes,
groups,
auth
});

View File

@@ -0,0 +1,71 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { AddButton } from "../../components/buttons";
import InputField from "../../components/forms/InputField";
import { isMemberNameValid } from "./groupValidation";
type Props = {
t: string => string,
addMember: string => void
};
type State = {
memberToAdd: string,
validationError: boolean
};
class AddMemberField extends React.Component<Props, State> {
constructor(props) {
super(props);
this.state = {
memberToAdd: "",
validationError: false
};
}
render() {
const { t } = this.props;
return (
<div className="field">
<InputField
label={t("add-member-textfield.label")}
errorMessage={t("add-member-textfield.error")}
onChange={this.handleAddMemberChange}
validationError={this.state.validationError}
value={this.state.memberToAdd}
onReturnPressed={this.appendMember}
/>
<AddButton
label={t("add-member-button.label")}
action={this.addButtonClicked}
disabled={!isMemberNameValid(this.state.memberToAdd)}
/>
</div>
);
}
addButtonClicked = (event: Event) => {
event.preventDefault();
this.appendMember();
};
appendMember = () => {
const { memberToAdd } = this.state;
if (isMemberNameValid(memberToAdd)) {
this.props.addMember(memberToAdd);
this.setState({ ...this.state, memberToAdd: "" });
}
};
handleAddMemberChange = (membername: string) => {
this.setState({
...this.state,
memberToAdd: membername,
validationError: membername.length > 0 && !isMemberNameValid(membername)
});
};
}
export default translate("groups")(AddMemberField);

View File

@@ -0,0 +1,150 @@
//@flow
import React from "react";
import InputField from "../../components/forms/InputField";
import { SubmitButton } from "../../components/buttons";
import { translate } from "react-i18next";
import type { Group } from "../types/Group";
import * as validator from "./groupValidation";
import AddMemberField from "./AddMemberField";
import MemberNameTable from "./MemberNameTable";
type Props = {
t: string => string,
submitForm: Group => void,
loading?: boolean,
group?: Group
};
type State = {
group: Group,
nameValidationError: boolean
};
class GroupForm extends React.Component<Props, State> {
constructor(props) {
super(props);
this.state = {
group: {
name: "",
description: "",
_embedded: {
members: []
},
_links: {},
members: [],
type: ""
},
nameValidationError: false
};
}
componentDidMount() {
const { group } = this.props;
if (group) {
this.setState({ ...this.state, group: { ...group } });
}
}
isFalsy(value) {
if (!value) {
return true;
}
return false;
}
isValid = () => {
const group = this.state.group;
return !(this.state.nameValidationError || this.isFalsy(group.name));
};
submit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.submitForm(this.state.group);
}
};
render() {
const { t, loading } = this.props;
const group = this.state.group;
let nameField = null;
if (!this.props.group) {
nameField = (
<InputField
label={t("group.name")}
errorMessage={t("group-form.name-error")}
onChange={this.handleGroupNameChange}
value={group.name}
validationError={this.state.nameValidationError}
/>
);
}
return (
<form onSubmit={this.submit}>
{nameField}
<InputField
label={t("group.description")}
errorMessage={t("group-form.description-error")}
onChange={this.handleDescriptionChange}
value={group.description}
validationError={false}
/>
<MemberNameTable
members={this.state.group.members}
memberListChanged={this.memberListChanged}
/>
<AddMemberField addMember={this.addMember} />
<SubmitButton
disabled={!this.isValid()}
label={t("group-form.submit")}
loading={loading}
/>
</form>
);
}
memberListChanged = membernames => {
this.setState({
...this.state,
group: {
...this.state.group,
members: membernames
}
});
};
addMember = (membername: string) => {
if (this.isMember(membername)) {
return;
}
this.setState({
...this.state,
group: {
...this.state.group,
members: [...this.state.group.members, membername]
}
});
};
isMember = (membername: string) => {
return this.state.group.members.includes(membername);
};
handleGroupNameChange = (name: string) => {
this.setState({
nameValidationError: !validator.isNameValid(name),
group: { ...this.state.group, name }
});
};
handleDescriptionChange = (description: string) => {
this.setState({
group: { ...this.state.group, description }
});
};
}
export default translate("groups")(GroupForm);

View File

@@ -0,0 +1,47 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import RemoveMemberButton from "./buttons/RemoveMemberButton";
type Props = {
members: string[],
t: string => string,
memberListChanged: (string[]) => void
};
type State = {};
class MemberNameTable extends React.Component<Props, State> {
render() {
const { t } = this.props;
return (
<div>
<label className="label">{t("group.members")}</label>
<table className="table is-hoverable is-fullwidth">
<tbody>
{this.props.members.map(member => {
return (
<tr key={member}>
<td key={member}>{member}</td>
<td>
<RemoveMemberButton
membername={member}
removeMember={this.removeMember}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
removeMember = (membername: string) => {
const newMembers = this.props.members.filter(name => name !== membername);
this.props.memberListChanged(newMembers);
};
}
export default translate("groups")(MemberNameTable);

View File

@@ -0,0 +1,19 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { CreateButton } from "../../../components/buttons";
type Props = {
t: string => string
};
class CreateGroupButton extends React.Component<Props> {
render() {
const { t } = this.props;
return (
<CreateButton label={t("create-group-button.label")} link="/groups/add" />
);
}
}
export default translate("groups")(CreateGroupButton);

View File

@@ -0,0 +1,34 @@
//@flow
import React from "react";
import { DeleteButton } from "../../../components/buttons";
import { translate } from "react-i18next";
import classNames from "classnames";
type Props = {
t: string => string,
membername: string,
removeMember: string => void
};
type State = {};
class RemoveMemberButton extends React.Component<Props, State> {
render() {
const { t , membername, removeMember} = this.props;
return (
<div className={classNames("is-pulled-right")}>
<DeleteButton
label={t("remove-member-button.label")}
action={(event: Event) => {
event.preventDefault();
removeMember(membername);
}}
/>
</div>
);
}
}
export default translate("groups")(RemoveMemberButton);

View File

@@ -0,0 +1,8 @@
// @flow
import { isNameValid } from "../../components/validation";
export { isNameValid };
export const isMemberNameValid = (name: string) => {
return isNameValid(name);
};

View File

@@ -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<Props> {
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 <NavAction label={t("delete-group-button.label")} action={action} />;
}
}
export default translate("groups")(DeleteGroupNavLink);

View File

@@ -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(
<DeleteGroupNavLink group={group} deleteGroup={() => {}} />
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const group = {
_links: {
delete: {
href: "/groups"
}
}
};
const navLink = mount(
<DeleteGroupNavLink group={group} deleteGroup={() => {}} />
);
expect(navLink.text()).not.toBe("");
});
it("should open the confirm dialog on navLink click", () => {
const group = {
_links: {
delete: {
href: "/groups"
}
}
};
const navLink = mount(
<DeleteGroupNavLink group={group} deleteGroup={() => {}} />
);
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(
<DeleteGroupNavLink
group={group}
confirmDialog={false}
deleteGroup={capture}
/>
);
navLink.find("a").simulate("click");
expect(calledUrl).toBe("/groups");
});
});

View File

@@ -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<Props, State> {
render() {
const { t, editUrl } = this.props;
if (!this.isEditable()) {
return null;
}
return <NavLink label={t("edit-group-button.label")} to={editUrl} />;
}
isEditable = () => {
return this.props.group._links.update;
}
}
export default translate("groups")(EditGroupNavLink);

View File

@@ -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(<EditGroupNavLink group={group} editUrl='/group/edit'/>);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const group = {
_links: {
update: {
href: "/groups"
}
}
};
const navLink = shallow(<EditGroupNavLink group={group} editUrl='/group/edit'/>);
expect(navLink.text()).not.toBe("");
});

View File

@@ -0,0 +1,2 @@
export { default as DeleteGroupNavLink } from "./DeleteGroupNavLink";
export { default as EditGroupNavLink } from "./EditGroupNavLink";

View File

@@ -0,0 +1,56 @@
//@flow
import React from "react";
import type { Group } from "../../types/Group";
import { translate } from "react-i18next";
import GroupMember from "./GroupMember";
type Props = {
group: Group,
t: string => string
};
class Details extends React.Component<Props> {
render() {
const { group, t } = this.props;
return (
<table className="table content">
<tbody>
<tr>
<td>{t("group.name")}</td>
<td>{group.name}</td>
</tr>
<tr>
<td>{t("group.description")}</td>
<td>{group.description}</td>
</tr>
<tr>
<td>{t("group.type")}</td>
<td>{group.type}</td>
</tr>
{this.renderMembers()}
</tbody>
</table>
);
}
renderMembers() {
if (this.props.group.members.length > 0) {
return (
<tr>
<td>
{this.props.t("group.members")}
<ul>
{this.props.group._embedded.members.map((member, index) => {
return <GroupMember key={index} member={member} />;
})}
</ul>
</td>
</tr>
);
} else {
return;
}
}
}
export default translate("groups")(Details);

View File

@@ -0,0 +1,28 @@
// @flow
import React from "react";
import { Link } from "react-router-dom";
import type { Member } from "../../types/Group";
type Props = {
member: Member
};
export default class GroupMember extends React.Component<Props> {
renderLink(to: string, label: string) {
return <Link to={to}>{label}</Link>;
}
showName(to: any, member: Member) {
if (member._links.self) {
return this.renderLink(to, member.name);
} else {
return member.name;
}
}
render() {
const { member } = this.props;
const to = `/user/${member.name}`;
return <li>{this.showName(to, member)}</li>;
}
}

View File

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

View File

@@ -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<Props> {
render() {
const { groups, t } = this.props;
return (
<table className="table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{t("group.name")}</th>
<th className="is-hidden-mobile">{t("group.description")}</th>
</tr>
</thead>
<tbody>
{groups.map((group, index) => {
return <GroupRow key={index} group={group} />;
})}
</tbody>
</table>
);
}
}
export default translate("groups")(GroupTable);

View File

@@ -0,0 +1,3 @@
export { default as Details } from "./Details";
export { default as GroupRow } from "./GroupRow";
export { default as GroupTable } from "./GroupTable";

View File

@@ -0,0 +1,69 @@
//@flow
import React from "react";
import Page from "../../components/layout/Page";
import { translate } from "react-i18next";
import GroupForm from "../components/GroupForm";
import { connect } from "react-redux";
import { createGroup, isCreateGroupPending, getCreateGroupFailure, createGroupReset } from "../modules/groups";
import type { Group } from "../types/Group";
import type { History } from "history";
type Props = {
t: string => string,
createGroup: (group: Group, callback?: () => void) => void,
history: History,
loading?: boolean,
error?: Error,
resetForm: () => void,
};
type State = {};
class AddGroup extends React.Component<Props, State> {
componentDidMount() {
this.props.resetForm();
}
render() {
const { t, loading, error } = this.props;
return (
<Page title={t("add-group.title")} subtitle={t("add-group.subtitle")} error={error}>
<div>
<GroupForm submitForm={group => this.createGroup(group)} loading={loading}/>
</div>
</Page>
);
}
groupCreated = () => {
this.props.history.push("/groups");
};
createGroup = (group: Group) => {
this.props.createGroup(group, this.groupCreated);
};
}
const mapDispatchToProps = dispatch => {
return {
createGroup: (group: Group, callback?: () => void) =>
dispatch(createGroup(group, callback)),
resetForm: () => {
dispatch(createGroupReset());
}
};
};
const mapStateToProps = state => {
const loading = isCreateGroupPending(state);
const error = getCreateGroupFailure(state);
return {
loading,
error
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("groups")(AddGroup));

View File

@@ -0,0 +1,71 @@
//@flow
import React from "react";
import { connect } from "react-redux";
import GroupForm from "../components/GroupForm";
import { modifyGroup, fetchGroup } from "../modules/groups";
import type { History } from "history";
import { withRouter } from "react-router-dom";
import type { Group } from "../types/Group";
import { isModifyGroupPending, getModifyGroupFailure } from "../modules/groups";
import ErrorNotification from "../../components/ErrorNotification";
type Props = {
group: Group,
modifyGroup: (group: Group, callback?: () => void) => void,
fetchGroup: (name: string) => void,
history: History,
loading?: boolean,
error: Error
};
class EditGroup extends React.Component<Props> {
groupModified = (group: Group) => () => {
this.props.fetchGroup(group.name);
this.props.history.push(`/group/${group.name}`);
};
modifyGroup = (group: Group) => {
this.props.modifyGroup(group, this.groupModified(group));
};
render() {
const { group, loading, error } = this.props;
return (
<div>
<ErrorNotification error={error} />
<GroupForm
group={group}
submitForm={group => {
this.modifyGroup(group);
}}
loading={loading}
/>
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
const loading = isModifyGroupPending(state, ownProps.group.name);
const error = getModifyGroupFailure(state, ownProps.group.name);
return {
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
modifyGroup: (group: Group, callback?: () => void) => {
dispatch(modifyGroup(group, callback));
},
fetchGroup: (name: string) => {
dispatch(fetchGroup(name));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(withRouter(EditGroup));

View File

@@ -0,0 +1,139 @@
//@flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import type { Group } from "../types/Group.js";
import type { PagedCollection } from "../../types/Collection";
import type { History } from "history";
import { Page } from "../../components/layout";
import { GroupTable } from "./../components/table";
import Paginator from "../../components/Paginator";
import CreateGroupButton from "../components/buttons/CreateGroupButton";
import {
fetchGroupsByPage,
fetchGroupsByLink,
getGroupsFromState,
isFetchGroupsPending,
getFetchGroupsFailure,
isPermittedToCreateGroups,
selectListAsCollection
} from "../modules/groups";
type Props = {
groups: Group[],
loading: boolean,
error: Error,
canAddGroups: boolean,
list: PagedCollection,
page: number,
// context objects
t: string => string,
history: History,
// dispatch functions
fetchGroupsByPage: (page: number) => void,
fetchGroupsByLink: (link: string) => void
};
class Groups extends React.Component<Props> {
componentDidMount() {
this.props.fetchGroupsByPage(this.props.page);
}
onPageChange = (link: string) => {
this.props.fetchGroupsByLink(link);
};
/**
* reflect page transitions in the uri
*/
componentDidUpdate = (prevProps: Props) => {
const { page, list } = this.props;
if (list.page >= 0) {
// backend starts paging by 0
const statePage: number = list.page + 1;
if (page !== statePage) {
this.props.history.push(`/groups/${statePage}`);
}
}
};
render() {
const { groups, loading, error, t } = this.props;
return (
<Page
title={t("groups.title")}
subtitle={t("groups.subtitle")}
loading={loading || !groups}
error={error}
>
<GroupTable groups={groups} />
{this.renderPaginator()}
{this.renderCreateButton()}
</Page>
);
}
renderPaginator() {
const { list } = this.props;
if (list) {
return <Paginator collection={list} onPageChange={this.onPageChange} />;
}
return null;
}
renderCreateButton() {
if (this.props.canAddGroups) {
return <CreateGroupButton />;
} else {
return;
}
}
}
const getPageFromProps = props => {
let page = props.match.params.page;
if (page) {
page = parseInt(page, 10);
} else {
page = 1;
}
return page;
};
const mapStateToProps = (state, ownProps) => {
const groups = getGroupsFromState(state);
const loading = isFetchGroupsPending(state);
const error = getFetchGroupsFailure(state);
const page = getPageFromProps(ownProps);
const canAddGroups = isPermittedToCreateGroups(state);
const list = selectListAsCollection(state);
return {
groups,
loading,
error,
canAddGroups,
list,
page
};
};
const mapDispatchToProps = dispatch => {
return {
fetchGroupsByPage: (page: number) => {
dispatch(fetchGroupsByPage(page));
},
fetchGroupsByLink: (link: string) => {
dispatch(fetchGroupsByLink(link));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("groups")(Groups));

View File

@@ -0,0 +1,143 @@
//@flow
import React from "react";
import { connect } from "react-redux";
import { Page } from "../../components/layout";
import { Route } from "react-router";
import { Details } from "./../components/table";
import { DeleteGroupNavLink, EditGroupNavLink } from "./../components/navLinks";
import type { Group } from "../types/Group";
import type { History } from "history";
import {
deleteGroup,
fetchGroup,
getGroupByName,
isFetchGroupPending,
getFetchGroupFailure,
getDeleteGroupFailure,
isDeleteGroupPending,
} from "../modules/groups";
import Loading from "../../components/Loading";
import { Navigation, Section, NavLink } from "../../components/navigation";
import ErrorPage from "../../components/ErrorPage";
import { translate } from "react-i18next";
import EditGroup from "./EditGroup";
type Props = {
name: string,
group: Group,
loading: boolean,
error: Error,
// dispatcher functions
deleteGroup: (group: Group, callback?: () => void) => void,
fetchGroup: string => void,
// context objects
t: string => string,
match: any,
history: History
};
class SingleGroup extends React.Component<Props> {
componentDidMount() {
this.props.fetchGroup(this.props.name);
}
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
deleteGroup = (group: Group) => {
this.props.deleteGroup(group, this.groupDeleted);
};
groupDeleted = () => {
this.props.history.push("/groups");
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { t, loading, error, group } = this.props;
if (error) {
return (
<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} />} />
<Route path={`${url}/edit`} exact component={() => <EditGroup 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")}>
<DeleteGroupNavLink group={group} deleteGroup={this.deleteGroup} />
<EditGroupNavLink group={group} editUrl={`${url}/edit`}/>
<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) || isDeleteGroupPending(state, name);
const error =
getFetchGroupFailure(state, name) || getDeleteGroupFailure(state, name);
return {
name,
group,
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
fetchGroup: (name: string) => {
dispatch(fetchGroup(name));
},
deleteGroup: (group: Group, callback?: () => void) => {
dispatch(deleteGroup(group, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("groups")(SingleGroup));

View File

@@ -0,0 +1,473 @@
// @flow
import { apiClient } from "../../apiclient";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
import * as types from "../../modules/types";
import { combineReducers, Dispatch } from "redux";
import type { Action } from "../../types/Action";
import type { PagedCollection } from "../../types/Collection";
import type { Group } from "../types/Group";
export const FETCH_GROUPS = "scm/groups/FETCH_GROUPS";
export const FETCH_GROUPS_PENDING = `${FETCH_GROUPS}_${types.PENDING_SUFFIX}`;
export const FETCH_GROUPS_SUCCESS = `${FETCH_GROUPS}_${types.SUCCESS_SUFFIX}`;
export const FETCH_GROUPS_FAILURE = `${FETCH_GROUPS}_${types.FAILURE_SUFFIX}`;
export const FETCH_GROUP = "scm/groups/FETCH_GROUP";
export const FETCH_GROUP_PENDING = `${FETCH_GROUP}_${types.PENDING_SUFFIX}`;
export const FETCH_GROUP_SUCCESS = `${FETCH_GROUP}_${types.SUCCESS_SUFFIX}`;
export const FETCH_GROUP_FAILURE = `${FETCH_GROUP}_${types.FAILURE_SUFFIX}`;
export const CREATE_GROUP = "scm/groups/CREATE_GROUP";
export const CREATE_GROUP_PENDING = `${CREATE_GROUP}_${types.PENDING_SUFFIX}`;
export const CREATE_GROUP_SUCCESS = `${CREATE_GROUP}_${types.SUCCESS_SUFFIX}`;
export const CREATE_GROUP_FAILURE = `${CREATE_GROUP}_${types.FAILURE_SUFFIX}`;
export const CREATE_GROUP_RESET = `${CREATE_GROUP}_${types.RESET_SUFFIX}`;
export const MODIFY_GROUP = "scm/groups/MODIFY_GROUP";
export const MODIFY_GROUP_PENDING = `${MODIFY_GROUP}_${types.PENDING_SUFFIX}`;
export const MODIFY_GROUP_SUCCESS = `${MODIFY_GROUP}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_GROUP_FAILURE = `${MODIFY_GROUP}_${types.FAILURE_SUFFIX}`;
export const DELETE_GROUP = "scm/groups/DELETE";
export const DELETE_GROUP_PENDING = `${DELETE_GROUP}_${types.PENDING_SUFFIX}`;
export const DELETE_GROUP_SUCCESS = `${DELETE_GROUP}_${types.SUCCESS_SUFFIX}`;
export const DELETE_GROUP_FAILURE = `${DELETE_GROUP}_${types.FAILURE_SUFFIX}`;
const GROUPS_URL = "groups";
const CONTENT_TYPE_GROUP = "application/vnd.scmm-group+json;v=2";
// fetch groups
export function fetchGroups() {
return fetchGroupsByLink(GROUPS_URL);
}
export function fetchGroupsByPage(page: number) {
// backend start counting by 0
return fetchGroupsByLink(GROUPS_URL + "?page=" + (page - 1));
}
export function fetchGroupsByLink(link: string) {
return function(dispatch: any) {
dispatch(fetchGroupsPending());
return apiClient
.get(link)
.then(response => response.json())
.then(data => {
dispatch(fetchGroupsSuccess(data));
})
.catch(cause => {
const error = new Error(`could not fetch groups: ${cause.message}`);
dispatch(fetchGroupsFailure(GROUPS_URL, error));
});
};
}
export function fetchGroupsPending(): Action {
return {
type: FETCH_GROUPS_PENDING
};
}
export function fetchGroupsSuccess(groups: any): Action {
return {
type: FETCH_GROUPS_SUCCESS,
payload: groups
};
}
export function fetchGroupsFailure(url: string, error: Error): Action {
return {
type: FETCH_GROUPS_FAILURE,
payload: {
error,
url
}
};
}
//fetch group
export function fetchGroup(name: string) {
const groupUrl = GROUPS_URL + "/" + name;
return function(dispatch: any) {
dispatch(fetchGroupPending(name));
return apiClient
.get(groupUrl)
.then(response => {
return response.json();
})
.then(data => {
dispatch(fetchGroupSuccess(data));
})
.catch(cause => {
const error = new Error(`could not fetch group: ${cause.message}`);
dispatch(fetchGroupFailure(name, error));
});
};
}
export function fetchGroupPending(name: string): Action {
return {
type: FETCH_GROUP_PENDING,
payload: name,
itemId: name
};
}
export function fetchGroupSuccess(group: any): Action {
return {
type: FETCH_GROUP_SUCCESS,
payload: group,
itemId: group.name
};
}
export function fetchGroupFailure(name: string, error: Error): Action {
return {
type: FETCH_GROUP_FAILURE,
payload: {
name,
error
},
itemId: name
};
}
//create group
export function createGroup(group: Group, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(createGroupPending());
return apiClient
.post(GROUPS_URL, group, CONTENT_TYPE_GROUP)
.then(() => {
dispatch(createGroupSuccess());
if (callback) {
callback();
}
})
.catch(error => {
dispatch(
createGroupFailure(
new Error(`Failed to create group ${group.name}: ${error.message}`)
)
);
});
};
}
export function createGroupPending() {
return {
type: CREATE_GROUP_PENDING
};
}
export function createGroupSuccess() {
return {
type: CREATE_GROUP_SUCCESS
};
}
export function createGroupFailure(error: Error) {
return {
type: CREATE_GROUP_FAILURE,
payload: error
};
}
export function createGroupReset() {
return {
type: CREATE_GROUP_RESET
};
}
// modify group
export function modifyGroup(group: Group, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(modifyGroupPending(group));
return apiClient
.put(group._links.update.href, group, CONTENT_TYPE_GROUP)
.then(() => {
dispatch(modifyGroupSuccess(group));
if (callback) {
callback();
}
})
.catch(cause => {
dispatch(
modifyGroupFailure(
group,
new Error(`could not modify group ${group.name}: ${cause.message}`)
)
);
});
};
}
export function modifyGroupPending(group: Group): Action {
return {
type: MODIFY_GROUP_PENDING,
payload: group,
itemId: group.name
};
}
export function modifyGroupSuccess(group: Group): Action {
return {
type: MODIFY_GROUP_SUCCESS,
payload: group,
itemId: group.name
};
}
export function modifyGroupFailure(group: Group, error: Error): Action {
return {
type: MODIFY_GROUP_FAILURE,
payload: {
error,
group
},
itemId: group.name
};
}
//delete group
export function deleteGroup(group: Group, callback?: () => void) {
return function(dispatch: any) {
dispatch(deleteGroupPending(group));
return apiClient
.delete(group._links.delete.href)
.then(() => {
dispatch(deleteGroupSuccess(group));
if (callback) {
callback();
}
})
.catch(cause => {
const error = new Error(
`could not delete group ${group.name}: ${cause.message}`
);
dispatch(deleteGroupFailure(group, error));
});
};
}
export function deleteGroupPending(group: Group): Action {
return {
type: DELETE_GROUP_PENDING,
payload: group,
itemId: group.name
};
}
export function deleteGroupSuccess(group: Group): Action {
return {
type: DELETE_GROUP_SUCCESS,
payload: group,
itemId: group.name
};
}
export function deleteGroupFailure(group: Group, error: Error): Action {
return {
type: DELETE_GROUP_FAILURE,
payload: {
error,
group
},
itemId: group.name
};
}
//reducer
function extractGroupsByNames(
groups: Group[],
groupNames: string[],
oldGroupsByNames: Object
) {
const groupsByNames = {};
for (let group of groups) {
groupsByNames[group.name] = group;
}
for (let groupName in oldGroupsByNames) {
groupsByNames[groupName] = oldGroupsByNames[groupName];
}
return groupsByNames;
}
function deleteGroupInGroupsByNames(groups: {}, groupName: string) {
let newGroups = {};
for (let groupname in groups) {
if (groupname !== groupName) newGroups[groupname] = groups[groupname];
}
return newGroups;
}
function deleteGroupInEntries(groups: [], groupName: string) {
let newGroups = [];
for (let group of groups) {
if (group !== groupName) newGroups.push(group);
}
return newGroups;
}
const reducerByName = (state: any, groupname: string, newGroupState: any) => {
const newGroupsByNames = {
...state,
[groupname]: newGroupState
};
return newGroupsByNames;
};
function listReducer(state: any = {}, action: any = {}) {
switch (action.type) {
case FETCH_GROUPS_SUCCESS:
const groups = action.payload._embedded.groups;
const groupNames = groups.map(group => group.name);
return {
...state,
entries: groupNames,
entry: {
groupCreatePermission: action.payload._links.create ? true : false,
page: action.payload.page,
pageTotal: action.payload.pageTotal,
_links: action.payload._links
}
};
// Delete single group actions
case DELETE_GROUP_SUCCESS:
const newGroupEntries = deleteGroupInEntries(
state.entries,
action.payload.name
);
return {
...state,
entries: newGroupEntries
};
default:
return state;
}
}
function byNamesReducer(state: any = {}, action: any = {}) {
switch (action.type) {
// Fetch all groups actions
case FETCH_GROUPS_SUCCESS:
const groups = action.payload._embedded.groups;
const groupNames = groups.map(group => group.name);
const byNames = extractGroupsByNames(groups, groupNames, state.byNames);
return {
...byNames
};
case FETCH_GROUP_SUCCESS:
return reducerByName(state, action.payload.name, action.payload);
case MODIFY_GROUP_SUCCESS:
return reducerByName(state, action.payload.name, action.payload);
case DELETE_GROUP_SUCCESS:
const newGroupByNames = deleteGroupInGroupsByNames(
state,
action.payload.name
);
return newGroupByNames;
default:
return state;
}
}
export default combineReducers({
list: listReducer,
byNames: byNamesReducer
});
// selectors
const selectList = (state: Object) => {
if (state.groups && state.groups.list) {
return state.groups.list;
}
return {};
};
const selectListEntry = (state: Object): Object => {
const list = selectList(state);
if (list.entry) {
return list.entry;
}
return {};
};
export const selectListAsCollection = (state: Object): PagedCollection => {
return selectListEntry(state);
};
export const isPermittedToCreateGroups = (state: Object): boolean => {
const permission = selectListEntry(state).groupCreatePermission;
if (permission) {
return true;
}
return false;
};
export function getGroupsFromState(state: Object) {
const groupNames = selectList(state).entries;
if (!groupNames) {
return null;
}
const groupEntries: Group[] = [];
for (let groupName of groupNames) {
groupEntries.push(state.groups.byNames[groupName]);
}
return groupEntries;
}
export function isFetchGroupsPending(state: Object) {
return isPending(state, FETCH_GROUPS);
}
export function getFetchGroupsFailure(state: Object) {
return getFailure(state, FETCH_GROUPS);
}
export function isCreateGroupPending(state: Object) {
return isPending(state, CREATE_GROUP);
}
export function getCreateGroupFailure(state: Object) {
return getFailure(state, CREATE_GROUP);
}
export function isModifyGroupPending(state: Object, name: string) {
return isPending(state, MODIFY_GROUP, name);
}
export function getModifyGroupFailure(state: Object, name: string) {
return getFailure(state, MODIFY_GROUP, name);
}
export function getGroupByName(state: Object, name: string) {
if (state.groups && state.groups.byNames) {
return state.groups.byNames[name];
}
}
export function isFetchGroupPending(state: Object, name: string) {
return isPending(state, FETCH_GROUP, name);
}
export function getFetchGroupFailure(state: Object, name: string) {
return getFailure(state, FETCH_GROUP, name);
}
export function isDeleteGroupPending(state: Object, name: string) {
return isPending(state, DELETE_GROUP, name);
}
export function getDeleteGroupFailure(state: Object, name: string) {
return getFailure(state, DELETE_GROUP, name);
}

View File

@@ -0,0 +1,632 @@
//@flow
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
fetchGroups,
FETCH_GROUPS,
FETCH_GROUPS_PENDING,
FETCH_GROUPS_SUCCESS,
FETCH_GROUPS_FAILURE,
fetchGroupsSuccess,
isPermittedToCreateGroups,
getGroupsFromState,
getFetchGroupsFailure,
isFetchGroupsPending,
selectListAsCollection,
fetchGroup,
FETCH_GROUP_PENDING,
FETCH_GROUP_SUCCESS,
FETCH_GROUP_FAILURE,
fetchGroupSuccess,
getFetchGroupFailure,
FETCH_GROUP,
isFetchGroupPending,
getGroupByName,
createGroup,
CREATE_GROUP_SUCCESS,
CREATE_GROUP_PENDING,
CREATE_GROUP_FAILURE,
isCreateGroupPending,
CREATE_GROUP,
getCreateGroupFailure,
deleteGroup,
DELETE_GROUP_PENDING,
DELETE_GROUP_SUCCESS,
DELETE_GROUP_FAILURE,
DELETE_GROUP,
deleteGroupSuccess,
isDeleteGroupPending,
getDeleteGroupFailure,
modifyGroup,
MODIFY_GROUP_PENDING,
MODIFY_GROUP_SUCCESS,
MODIFY_GROUP_FAILURE
} from "./groups";
const GROUPS_URL = "/scm/api/rest/v2/groups";
const error = new Error("You have an error!");
const humanGroup = {
creationDate: "2018-07-31T08:39:07.860Z",
description: "This is a group",
name: "humanGroup",
type: "xml",
properties: {},
members: ["userZaphod"],
_links: {
self: {
href: "http://localhost:8081/scm/api/rest/v2/groups/humanGroup"
},
delete: {
href: "http://localhost:8081/scm/api/rest/v2/groups/humanGroup"
},
update: {
href:"http://localhost:8081/scm/api/rest/v2/groups/humanGroup"
}
},
_embedded: {
members: [
{
name: "userZaphod",
_links: {
self: {
href: "http://localhost:8081/scm/api/rest/v2/users/userZaphod"
}
}
}
]
}
};
const emptyGroup = {
creationDate: "2018-07-31T08:39:07.860Z",
description: "This is a group",
name: "emptyGroup",
type: "xml",
properties: {},
members: [],
_links: {
self: {
href: "http://localhost:8081/scm/api/rest/v2/groups/emptyGroup"
},
delete: {
href: "http://localhost:8081/scm/api/rest/v2/groups/emptyGroup"
},
update: {
href:"http://localhost:8081/scm/api/rest/v2/groups/emptyGroup"
}
},
_embedded: {
members: []
}
};
const responseBody = {
page: 0,
pageTotal: 1,
_links: {
self: {
href: "http://localhost:3000/scm/api/rest/v2/groups/?page=0&pageSize=10"
},
first: {
href: "http://localhost:3000/scm/api/rest/v2/groups/?page=0&pageSize=10"
},
last: {
href: "http://localhost:3000/scm/api/rest/v2/groups/?page=0&pageSize=10"
},
create: {
href: "http://localhost:3000/scm/api/rest/v2/groups/"
}
},
_embedded: {
groups: [humanGroup, emptyGroup]
}
};
const response = {
headers: { "content-type": "application/json" },
responseBody
};
describe("groups fetch()", () => {
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch groups", () => {
fetchMock.getOnce(GROUPS_URL, response);
const expectedActions = [
{ type: FETCH_GROUPS_PENDING },
{
type: FETCH_GROUPS_SUCCESS,
payload: response
}
];
const store = mockStore({});
return store.dispatch(fetchGroups()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail getting groups on HTTP 500", () => {
fetchMock.getOnce(GROUPS_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchGroups()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_GROUPS_PENDING);
expect(actions[1].type).toEqual(FETCH_GROUPS_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should sucessfully fetch single group", () => {
fetchMock.getOnce(GROUPS_URL + "/humanGroup", humanGroup);
const store = mockStore({});
return store.dispatch(fetchGroup("humanGroup")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
expect(actions[1].type).toEqual(FETCH_GROUP_SUCCESS);
expect(actions[1].payload).toBeDefined();
});
});
it("should fail fetching single group on HTTP 500", () => {
fetchMock.getOnce(GROUPS_URL + "/humanGroup", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchGroup("humanGroup")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_GROUP_PENDING);
expect(actions[1].type).toEqual(FETCH_GROUP_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should successfully create group", () => {
fetchMock.postOnce(GROUPS_URL, {
status: 201
});
const store = mockStore({});
return store.dispatch(createGroup(humanGroup)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
});
});
it("should call the callback after creating group", () => {
fetchMock.postOnce(GROUPS_URL, {
status: 201
});
let called = false;
const callMe = () => {
called = true;
}
const store = mockStore({});
return store.dispatch(createGroup(humanGroup, callMe)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
expect(actions[1].type).toEqual(CREATE_GROUP_SUCCESS);
expect(called).toEqual(true);
});
});
it("should fail creating group on HTTP 500", () => {
fetchMock.postOnce(GROUPS_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(createGroup(humanGroup)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_GROUP_PENDING);
expect(actions[1].type).toEqual(CREATE_GROUP_FAILURE);
expect(actions[1].payload).toBeDefined();
expect(actions[1].payload instanceof Error).toBeTruthy();
});
});
it("should successfully modify group", () => {
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
status: 204
});
const store = mockStore({});
return store.dispatch(modifyGroup(humanGroup)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS);
expect(actions[1].payload).toEqual(humanGroup)
});
})
it("should call the callback after modifying group", () => {
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
status: 204
});
let called = false;
const callback = () => {
called = true;
}
const store = mockStore({});
return store.dispatch(modifyGroup(humanGroup, callback)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
expect(actions[1].type).toEqual(MODIFY_GROUP_SUCCESS);
expect(called).toBe(true);
});
})
it("should fail modifying group on HTTP 500", () => {
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
status: 500
});
const store = mockStore({});
return store.dispatch(modifyGroup(humanGroup)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_GROUP_PENDING);
expect(actions[1].type).toEqual(MODIFY_GROUP_FAILURE);
expect(actions[1].payload).toBeDefined();
});
})
it("should delete successfully group humanGroup", () => {
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
status: 204
});
const store = mockStore({});
return store.dispatch(deleteGroup(humanGroup)).then(() => {
const actions = store.getActions();
expect(actions.length).toBe(2);
expect(actions[0].type).toEqual(DELETE_GROUP_PENDING);
expect(actions[0].payload).toBe(humanGroup);
expect(actions[1].type).toEqual(DELETE_GROUP_SUCCESS);
});
});
it("should call the callback, after successful delete", () => {
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
status: 204
});
let called = false;
const callMe = () => {
called = true;
};
const store = mockStore({});
return store.dispatch(deleteGroup(humanGroup, callMe)).then(() => {
expect(called).toBeTruthy();
});
});
it("should fail to delete group humanGroup", () => {
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/groups/humanGroup", {
status: 500
});
const store = mockStore({});
return store.dispatch(deleteGroup(humanGroup)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(DELETE_GROUP_PENDING);
expect(actions[0].payload).toBe(humanGroup);
expect(actions[1].type).toEqual(DELETE_GROUP_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("groups reducer", () => {
it("should update state correctly according to FETCH_GROUPS_SUCCESS action", () => {
const newState = reducer({}, fetchGroupsSuccess(responseBody));
expect(newState.list).toEqual({
entries: ["humanGroup", "emptyGroup"],
entry: {
groupCreatePermission: true,
page: 0,
pageTotal: 1,
_links: responseBody._links
}
});
expect(newState.byNames).toEqual({
humanGroup: humanGroup,
emptyGroup: emptyGroup
});
expect(newState.list.entry.groupCreatePermission).toBeTruthy();
});
it("should set groupCreatePermission to true if update link is present", () => {
const newState = reducer({}, fetchGroupsSuccess(responseBody));
expect(newState.list.entry.groupCreatePermission).toBeTruthy();
});
it("should not replace whole byNames map when fetching groups", () => {
const oldState = {
byNames: {
emptyGroup: emptyGroup
}
};
const newState = reducer(oldState, fetchGroupsSuccess(responseBody));
expect(newState.byNames["humanGroup"]).toBeDefined();
expect(newState.byNames["emptyGroup"]).toBeDefined();
});
it("should set groupCreatePermission to true if create link is present", () => {
const newState = reducer({}, fetchGroupsSuccess(responseBody));
expect(newState.list.entry.groupCreatePermission).toBeTruthy();
expect(newState.list.entries).toEqual(["humanGroup", "emptyGroup"]);
expect(newState.byNames["emptyGroup"]).toBeTruthy();
expect(newState.byNames["humanGroup"]).toBeTruthy();
});
it("should update state according to FETCH_GROUP_SUCCESS action", () => {
const newState = reducer({}, fetchGroupSuccess(emptyGroup));
expect(newState.byNames["emptyGroup"]).toBe(emptyGroup);
});
it("should affect groups state nor the state of other groups", () => {
const newState = reducer(
{
list: {
entries: ["humanGroup"]
}
},
fetchGroupSuccess(emptyGroup)
);
expect(newState.byNames["emptyGroup"]).toBe(emptyGroup);
expect(newState.list.entries).toEqual(["humanGroup"]);
});
it("should remove group from state when delete succeeds", () => {
const state = {
list: {
entries: ["humanGroup", "emptyGroup"]
},
byNames: {
humanGroup: humanGroup,
emptyGroup: emptyGroup
}
};
const newState = reducer(state, deleteGroupSuccess(emptyGroup));
expect(newState.byNames["humanGroup"]).toBeDefined();
expect(newState.byNames["emptyGroup"]).toBeFalsy();
expect(newState.list.entries).toEqual(["humanGroup"]);
});
});
describe("selector tests", () => {
it("should return an empty object", () => {
expect(selectListAsCollection({})).toEqual({});
expect(selectListAsCollection({ groups: { a: "a" } })).toEqual({});
});
it("should return a state slice collection", () => {
const collection = {
page: 3,
totalPages: 42
};
const state = {
groups: {
list: {
entry: collection
}
}
};
expect(selectListAsCollection(state)).toBe(collection);
});
it("should return false when groupCreatePermission is false", () => {
expect(isPermittedToCreateGroups({})).toBe(false);
expect(isPermittedToCreateGroups({ groups: { list: { entry: {} } } })).toBe(
false
);
expect(
isPermittedToCreateGroups({
groups: { list: { entry: { groupCreatePermission: false } } }
})
).toBe(false);
});
it("should return true when groupCreatePermission is true", () => {
const state = {
groups: {
list: {
entry: {
groupCreatePermission: true
}
}
}
};
expect(isPermittedToCreateGroups(state)).toBe(true);
});
it("should get groups from state", () => {
const state = {
groups: {
list: {
entries: ["a", "b"]
},
byNames: {
a: { name: "a" },
b: { name: "b" }
}
}
};
expect(getGroupsFromState(state)).toEqual([{ name: "a" }, { name: "b" }]);
});
it("should return null when there are no groups in the state", () => {
expect(getGroupsFromState({})).toBe(null)
});
it("should return true, when fetch groups is pending", () => {
const state = {
pending: {
[FETCH_GROUPS]: true
}
};
expect(isFetchGroupsPending(state)).toEqual(true);
});
it("should return false, when fetch groups is not pending", () => {
expect(isFetchGroupsPending({})).toEqual(false);
});
it("should return error when fetch groups did fail", () => {
const state = {
failure: {
[FETCH_GROUPS]: error
}
};
expect(getFetchGroupsFailure(state)).toEqual(error);
});
it("should return undefined when fetch groups did not fail", () => {
expect(getFetchGroupsFailure({})).toBe(undefined);
});
it("should return group emptyGroup", () => {
const state = {
groups: {
byNames: {
emptyGroup: emptyGroup
}
}
};
expect(getGroupByName(state, "emptyGroup")).toEqual(emptyGroup);
});
it("should return true, when fetch group humanGroup is pending", () => {
const state = {
pending: {
[FETCH_GROUP + "/humanGroup"]: true
}
};
expect(isFetchGroupPending(state, "humanGroup")).toEqual(true);
});
it("should return false, when fetch group humanGroup is not pending", () => {
expect(isFetchGroupPending({}, "humanGroup")).toEqual(false);
});
it("should return error when fetch group humanGroup did fail", () => {
const state = {
failure: {
[FETCH_GROUP + "/humanGroup"]: error
}
};
expect(getFetchGroupFailure(state, "humanGroup")).toEqual(error);
});
it("should return undefined when fetch group humanGroup did not fail", () => {
expect(getFetchGroupFailure({}, "humanGroup")).toBe(undefined);
});
it("should return true if create group is pending", () => {
expect(isCreateGroupPending({pending: {
[CREATE_GROUP]: true
}})).toBeTruthy();
})
it("should return false if create group is not pending", () => {
expect(isCreateGroupPending({})).toBe(false);
})
it("should return error if creating group failed", () => {
expect(getCreateGroupFailure({
failure: {
[CREATE_GROUP]: error
}
})).toEqual(error)
})
it("should return undefined if creating group did not fail", () => {
expect(getCreateGroupFailure({})).toBeUndefined()
})
it("should return true, when delete group humanGroup is pending", () => {
const state = {
pending: {
[DELETE_GROUP + "/humanGroup"]: true
}
};
expect(isDeleteGroupPending(state, "humanGroup")).toEqual(true);
});
it("should return false, when delete group humanGroup is not pending", () => {
expect(isDeleteGroupPending({}, "humanGroup")).toEqual(false);
});
it("should return error when delete group humanGroup did fail", () => {
const state = {
failure: {
[DELETE_GROUP + "/humanGroup"]: error
}
};
expect(getDeleteGroupFailure(state, "humanGroup")).toEqual(error);
});
it("should return undefined when delete group humanGroup did not fail", () => {
expect(getDeleteGroupFailure({}, "humanGroup")).toBe(undefined);
});
it("should return true, if createGroup is pending", () => {
const state = {
pending: {
[CREATE_GROUP]: true
}
}
expect(isCreateGroupPending(state)).toBe(true);
})
it("should return false, if createGroup is not pending", () => {
expect(isCreateGroupPending({})).toBe(false)
})
it("should return error of createGroup failed", () => {
const state = {
failure: {
[CREATE_GROUP]: error
}
}
expect(getCreateGroupFailure(state)).toEqual(error)
})
});

View File

@@ -0,0 +1,18 @@
//@flow
import type { Collection } from "../../types/Collection";
import type { Links } from "../../types/hal";
export type Member = {
name: string,
_links: Links
};
export type Group = Collection & {
name: string,
description: string,
type: string,
members: string[],
_embedded: {
members: Member[]
}
};

View File

@@ -1,5 +1,9 @@
// @flow
import { isNameValid, isMailValid } from "../../components/validation";
export { isNameValid, isMailValid };
export const isDisplayNameValid = (displayName: string) => {
if (displayName) {
return true;

View File

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

View File

@@ -59,7 +59,7 @@ class Users extends React.Component<Props> {
this.props.history.push(`/users/${statePage}`);
}
}
};
}
render() {
const { users, loading, error, t } = this.props;

View File

@@ -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()));
}
}

View File

@@ -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());

View File

@@ -2,14 +2,9 @@
"description": "Tolle Gruppe",
"name": "dev",
"type": "developers",
"members": ["user1", "user2"],
"_embedded": {
"members": [
{
"name": "user1"
},
{
"name": "user2"
}
]
}
}