mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-08 22:45:45 +01:00
merge with branch feature/ui-for-scm2_groups
This commit is contained in:
2
scm-ui/.vscode/settings.json
vendored
2
scm-ui/.vscode/settings.json
vendored
@@ -4,7 +4,7 @@
|
||||
"editor.formatOnSave": false,
|
||||
// Enable per-language
|
||||
"[javascript]": {
|
||||
"editor.formatOnSave": true
|
||||
"editor.formatOnSave": false
|
||||
},
|
||||
"flow.pathToFlow": "${workspaceRoot}/node_modules/.bin/flow"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"primary-navigation": {
|
||||
"repositories": "Repositories",
|
||||
"users": "Users",
|
||||
"logout": "Logout"
|
||||
"logout": "Logout",
|
||||
"groups": "Groups"
|
||||
},
|
||||
"paginator": {
|
||||
"next": "Next",
|
||||
|
||||
56
scm-ui/public/locales/en/groups.json
Normal file
56
scm-ui/public/locales/en/groups.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
11
scm-ui/src/components/buttons/AddButton.js
Normal file
11
scm-ui/src/components/buttons/AddButton.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
71
scm-ui/src/groups/components/AddMemberField.js
Normal file
71
scm-ui/src/groups/components/AddMemberField.js
Normal 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);
|
||||
150
scm-ui/src/groups/components/GroupForm.js
Normal file
150
scm-ui/src/groups/components/GroupForm.js
Normal 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);
|
||||
47
scm-ui/src/groups/components/MemberNameTable.js
Normal file
47
scm-ui/src/groups/components/MemberNameTable.js
Normal 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);
|
||||
19
scm-ui/src/groups/components/buttons/CreateGroupButton.js
Normal file
19
scm-ui/src/groups/components/buttons/CreateGroupButton.js
Normal 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);
|
||||
34
scm-ui/src/groups/components/buttons/RemoveMemberButton.js
Normal file
34
scm-ui/src/groups/components/buttons/RemoveMemberButton.js
Normal 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);
|
||||
8
scm-ui/src/groups/components/groupValidation.js
Normal file
8
scm-ui/src/groups/components/groupValidation.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// @flow
|
||||
import { isNameValid } from "../../components/validation";
|
||||
|
||||
export { isNameValid };
|
||||
|
||||
export const isMemberNameValid = (name: string) => {
|
||||
return isNameValid(name);
|
||||
};
|
||||
57
scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js
Normal file
57
scm-ui/src/groups/components/navLinks/DeleteGroupNavLink.js
Normal 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);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
31
scm-ui/src/groups/components/navLinks/EditGroupNavLink.js
Normal file
31
scm-ui/src/groups/components/navLinks/EditGroupNavLink.js
Normal 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);
|
||||
@@ -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("");
|
||||
});
|
||||
2
scm-ui/src/groups/components/navLinks/index.js
Normal file
2
scm-ui/src/groups/components/navLinks/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as DeleteGroupNavLink } from "./DeleteGroupNavLink";
|
||||
export { default as EditGroupNavLink } from "./EditGroupNavLink";
|
||||
56
scm-ui/src/groups/components/table/Details.js
Normal file
56
scm-ui/src/groups/components/table/Details.js
Normal 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);
|
||||
28
scm-ui/src/groups/components/table/GroupMember.js
Normal file
28
scm-ui/src/groups/components/table/GroupMember.js
Normal 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>;
|
||||
}
|
||||
}
|
||||
25
scm-ui/src/groups/components/table/GroupRow.js
Normal file
25
scm-ui/src/groups/components/table/GroupRow.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
33
scm-ui/src/groups/components/table/GroupTable.js
Normal file
33
scm-ui/src/groups/components/table/GroupTable.js
Normal 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);
|
||||
3
scm-ui/src/groups/components/table/index.js
Normal file
3
scm-ui/src/groups/components/table/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Details } from "./Details";
|
||||
export { default as GroupRow } from "./GroupRow";
|
||||
export { default as GroupTable } from "./GroupTable";
|
||||
69
scm-ui/src/groups/containers/AddGroup.js
Normal file
69
scm-ui/src/groups/containers/AddGroup.js
Normal 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));
|
||||
71
scm-ui/src/groups/containers/EditGroup.js
Normal file
71
scm-ui/src/groups/containers/EditGroup.js
Normal 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));
|
||||
139
scm-ui/src/groups/containers/Groups.js
Normal file
139
scm-ui/src/groups/containers/Groups.js
Normal 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));
|
||||
143
scm-ui/src/groups/containers/SingleGroup.js
Normal file
143
scm-ui/src/groups/containers/SingleGroup.js
Normal 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));
|
||||
473
scm-ui/src/groups/modules/groups.js
Normal file
473
scm-ui/src/groups/modules/groups.js
Normal 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);
|
||||
}
|
||||
632
scm-ui/src/groups/modules/groups.test.js
Normal file
632
scm-ui/src/groups/modules/groups.test.js
Normal 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)
|
||||
})
|
||||
});
|
||||
18
scm-ui/src/groups/types/Group.js
Normal file
18
scm-ui/src/groups/types/Group.js
Normal 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[]
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,9 @@
|
||||
// @flow
|
||||
|
||||
import { isNameValid, isMailValid } from "../../components/validation";
|
||||
|
||||
export { isNameValid, isMailValid };
|
||||
|
||||
export const isDisplayNameValid = (displayName: string) => {
|
||||
if (displayName) {
|
||||
return true;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -59,7 +59,7 @@ class Users extends React.Component<Props> {
|
||||
this.props.history.push(`/users/${statePage}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { users, loading, error, t } = this.props;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -2,14 +2,9 @@
|
||||
"description": "Tolle Gruppe",
|
||||
"name": "dev",
|
||||
"type": "developers",
|
||||
"members": ["user1", "user2"],
|
||||
"_embedded": {
|
||||
"members": [
|
||||
{
|
||||
"name": "user1"
|
||||
},
|
||||
{
|
||||
"name": "user2"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user