aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main
diff options
context:
space:
mode:
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>2017-03-29 11:11:56 +0200
committerGrégoire Aubert <gregaubert@users.noreply.github.com>2017-03-31 10:29:27 +0200
commit19adabdc666a5513b014776ffc283a911a6b2316 (patch)
treeadd80ced12cbd0362591d41eebc1be3083548b00 /server/sonar-web/src/main
parent76ad0222a481f307474b2990f264780f42cf3627 (diff)
downloadsonarqube-19adabdc666a5513b014776ffc283a911a6b2316.tar.gz
sonarqube-19adabdc666a5513b014776ffc283a911a6b2316.zip
SONAR-8994 Add groups management in members view
Diffstat (limited to 'server/sonar-web/src/main')
-rw-r--r--server/sonar-web/src/main/js/api/user_groups.js43
-rw-r--r--server/sonar-web/src/main/js/api/users.js20
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/actions.js69
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/MembersList.js8
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js16
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js14
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembersContainer.js10
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap23
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/forms/ManageMemberGroupsForm.js155
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/AddMemberForm-test.js2
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/ManageMemberGroupsForm-test.js122
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/RemoveMemberForm-test.js9
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/ManageMemberGroupsForm-test.js.snap205
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/RemoveMemberForm-test.js.snap15
-rw-r--r--server/sonar-web/src/main/js/components/controls/Checkbox.js2
-rw-r--r--server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.js9
-rw-r--r--server/sonar-web/src/main/js/helpers/testUtils.js14
-rw-r--r--server/sonar-web/src/main/js/store/organizations/__tests__/__snapshots__/duck-test.js.snap3
-rw-r--r--server/sonar-web/src/main/js/store/organizations/duck.js40
-rw-r--r--server/sonar-web/src/main/js/store/rootReducer.js3
-rw-r--r--server/sonar-web/src/main/js/store/users/actions.js6
-rw-r--r--server/sonar-web/src/main/js/store/users/reducer.js4
-rw-r--r--server/sonar-web/src/main/less/init/lists.less10
-rw-r--r--server/sonar-web/src/main/less/init/misc.less4
24 files changed, 757 insertions, 49 deletions
diff --git a/server/sonar-web/src/main/js/api/user_groups.js b/server/sonar-web/src/main/js/api/user_groups.js
new file mode 100644
index 00000000000..becfdb9c8a0
--- /dev/null
+++ b/server/sonar-web/src/main/js/api/user_groups.js
@@ -0,0 +1,43 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+//@flow
+import { getJSON, post } from '../helpers/request';
+
+export function searchUsersGroups(query?: string, organization?: string) {
+ const url = '/api/user_groups/search';
+ const data: { q?: string, organization?: string } = {};
+ if (query) {
+ data.q = query;
+ }
+ if (organization) {
+ data.organization = organization;
+ }
+ return getJSON(url, data);
+}
+
+export function addUserToGroup(groupId: string, login: string) {
+ const url = '/api/user_groups/add_user';
+ return post(url, { id: groupId, login });
+}
+
+export function removeUserFromGroup(groupId: string, login: string) {
+ const url = '/api/user_groups/remove_user';
+ return post(url, { id: groupId, login });
+}
diff --git a/server/sonar-web/src/main/js/api/users.js b/server/sonar-web/src/main/js/api/users.js
index 2014d391454..c8a5e993449 100644
--- a/server/sonar-web/src/main/js/api/users.js
+++ b/server/sonar-web/src/main/js/api/users.js
@@ -17,6 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+//@flow
import { getJSON, post } from '../helpers/request';
export function getCurrentUser() {
@@ -24,25 +25,32 @@ export function getCurrentUser() {
return getJSON(url);
}
-export function changePassword(login, password, previousPassword) {
+export function changePassword(login: string, password: string, previousPassword?: string) {
const url = '/api/users/change_password';
- const data = { login, password };
-
+ const data: { login: string, password: string, previousPassword?: string } = { login, password };
if (previousPassword != null) {
data.previousPassword = previousPassword;
}
-
return post(url, data);
}
+export function getUserGroups(login: string, organization?: string) {
+ const url = '/api/users/groups';
+ const data: { login: string, organization?: string, q?: string } = { login };
+ if (organization) {
+ data.organization = organization;
+ }
+ return getJSON(url, data);
+}
+
export function getIdentityProviders() {
const url = '/api/users/identity_providers';
return getJSON(url);
}
-export function searchUsers(query, pageSize) {
+export function searchUsers(query: string, pageSize?: number) {
const url = '/api/users/search';
- const data = { q: query };
+ const data: { q: string, ps?: number } = { q: query };
if (pageSize != null) {
data.ps = pageSize;
}
diff --git a/server/sonar-web/src/main/js/apps/organizations/actions.js b/server/sonar-web/src/main/js/apps/organizations/actions.js
index 864ba1f448d..1782325a68e 100644
--- a/server/sonar-web/src/main/js/apps/organizations/actions.js
+++ b/server/sonar-web/src/main/js/apps/organizations/actions.js
@@ -21,6 +21,8 @@
import * as api from '../../api/organizations';
import * as actions from '../../store/organizations/duck';
import * as membersActions from '../../store/organizationsMembers/actions';
+import { searchUsersGroups, addUserToGroup, removeUserFromGroup } from '../../api/user_groups';
+import { receiveUser } from '../../store/users/actions';
import { onFail } from '../../store/rootActions';
import { getOrganizationMembersState } from '../../store/rootReducer';
import { addGlobalSuccessMessage } from '../../store/globalMessages/duck';
@@ -57,6 +59,16 @@ export const fetchOrganization = (key: string): Function =>
);
};
+export const fetchOrganizationGroups = (key: string): Function =>
+ (dispatch: Function): Promise<*> => {
+ return searchUsersGroups('', key).then(
+ response => {
+ dispatch(actions.receiveOrganizationGroups(key, response.groups));
+ },
+ onFail(dispatch)
+ );
+ };
+
export const createOrganization = (fields: {}): Function =>
(dispatch: Function): Promise<*> => {
const onFulfilled = (organization: Organization) => {
@@ -109,12 +121,14 @@ const fetchMembers = (
}
return api.searchMembers(data).then(
response => {
- dispatch(receiveAction(key, response.users, {
- loading: false,
- total: response.paging.total,
- pageIndex: response.paging.pageIndex,
- query: query || null
- }));
+ dispatch(
+ receiveAction(key, response.users, {
+ loading: false,
+ total: response.paging.total,
+ pageIndex: response.paging.pageIndex,
+ query: query || null
+ })
+ );
},
onMembersFail(key, dispatch)
);
@@ -133,18 +147,35 @@ export const fetchMoreOrganizationMembers = (key: string, query?: string) =>
getOrganizationMembersState(getState(), key).pageIndex + 1
);
-export const addOrganizationMember = (key: string, member: Member) => (dispatch: Function) => {
- dispatch(membersActions.addMember(key, member));
- return api.addMember({ login: member.login, organization: key }).catch((error: Object) => {
- onFail(dispatch)(error);
+export const addOrganizationMember = (key: string, member: Member) =>
+ (dispatch: Function) => {
+ dispatch(membersActions.addMember(key, member));
+ return api.addMember({ login: member.login, organization: key }).catch((error: Object) => {
+ onFail(dispatch)(error);
+ dispatch(membersActions.removeMember(key, member));
+ });
+ };
+
+export const removeOrganizationMember = (key: string, member: Member) =>
+ (dispatch: Function) => {
dispatch(membersActions.removeMember(key, member));
- });
-};
+ return api.removeMember({ login: member.login, organization: key }).catch((error: Object) => {
+ onFail(dispatch)(error);
+ dispatch(membersActions.addMember(key, member));
+ });
+ };
-export const removeOrganizationMember = (key: string, member: Member) => (dispatch: Function) => {
- dispatch(membersActions.removeMember(key, member));
- return api.removeMember({ login: member.login, organization: key }).catch((error: Object) => {
- onFail(dispatch)(error);
- dispatch(membersActions.addMember(key, member));
- });
-};
+export const updateOrganizationMemberGroups = (
+ member: Member,
+ add: Array<string>,
+ remove: Array<string>
+) =>
+ (dispatch: Function) => {
+ const promises = [
+ ...add.map(id => addUserToGroup(id, member.login)),
+ ...remove.map(id => removeUserFromGroup(id, member.login))
+ ];
+ return Promise.all(promises).then(() => {
+ dispatch(receiveUser({ ...member, groupCount: member.groupCount + add.length - remove.length }));
+ }, onFail(dispatch));
+ };
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js b/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js
index 55997c69ba5..9d1f87512f2 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js
+++ b/server/sonar-web/src/main/js/apps/organizations/components/MembersList.js
@@ -21,12 +21,14 @@
import React from 'react';
import MembersListItem from './MembersListItem';
import type { Member } from '../../../store/organizationsMembers/actions';
-import type { Organization } from '../../../store/organizations/duck';
+import type { Organization, OrgGroup } from '../../../store/organizations/duck';
type Props = {
members: Array<Member>,
- organization?: Organization,
+ organizationGroups: Array<OrgGroup>,
+ organization: Organization,
removeMember: (Member) => void,
+ updateMemberGroups: (member: Member, add: Array<string>, remove: Array<string>) => void
};
export default class MembersList extends React.PureComponent {
@@ -40,8 +42,10 @@ export default class MembersList extends React.PureComponent {
<MembersListItem
key={member.login}
member={member}
+ organizationGroups={this.props.organizationGroups}
organization={this.props.organization}
removeMember={this.props.removeMember}
+ updateMemberGroups={this.props.updateMemberGroups}
/>
))}
</tbody>
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js b/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js
index 0925526dfda..3c2f7c8815b 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js
+++ b/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js
@@ -23,13 +23,16 @@ import Avatar from '../../../components/ui/Avatar';
import { translate } from '../../../helpers/l10n';
import { formatMeasure } from '../../../helpers/measures';
import RemoveMemberForm from './forms/RemoveMemberForm';
+import ManageMemberGroupsForm from './forms/ManageMemberGroupsForm';
import type { Member } from '../../../store/organizationsMembers/actions';
-import type { Organization } from '../../../store/organizations/duck';
+import type { Organization, OrgGroup } from '../../../store/organizations/duck';
type Props = {
member: Member,
organization: Organization,
+ organizationGroups: Array<OrgGroup>,
removeMember: (Member) => void,
+ updateMemberGroups: (member: Member, add: Array<string>, remove: Array<string>) => void
};
const AVATAR_SIZE: number = 36;
@@ -53,7 +56,7 @@ export default class MembersListItem extends React.PureComponent {
<td className="nowrap text-middle text-right">
<div className="dropdown">
<button className="dropdown-toggle little-spacer-right" data-toggle="dropdown">
- <i className="icon-edit" />
+ <i className="icon-settings" />
{' '}
<i className="icon-dropdown" />
</button>
@@ -65,6 +68,15 @@ export default class MembersListItem extends React.PureComponent {
member={this.props.member}
/>
</li>
+ <li role="separator" className="divider" />
+ <li>
+ <ManageMemberGroupsForm
+ organizationGroups={this.props.organizationGroups}
+ organization={this.props.organization}
+ updateMemberGroups={this.props.updateMemberGroups}
+ member={this.props.member}
+ />
+ </li>
</ul>
</div>
</td>}
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js
index b91fe00207d..6caa05de692 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js
+++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembers.js
@@ -24,18 +24,21 @@ import MembersList from './MembersList';
import AddMemberForm from './forms/AddMemberForm';
import UsersSearch from '../../users/components/UsersSearch';
import ListFooter from '../../../components/controls/ListFooter';
-import type { Organization } from '../../../store/organizations/duck';
+import type { Organization, OrgGroup } from '../../../store/organizations/duck';
import type { Member } from '../../../store/organizationsMembers/actions';
type Props = {
members: Array<Member>,
memberLogins: Array<string>,
+ organizationGroups: Array<OrgGroup>,
status: { loading?: boolean, total?: number, pageIndex?: number, query?: string },
organization: Organization,
fetchOrganizationMembers: (organizationKey: string, query?: string) => void,
fetchMoreOrganizationMembers: (organizationKey: string, query?: string) => void,
- addOrganizationMember: (organizationKey: string, login: Member) => void,
- removeOrganizationMember: (organizationKey: string, login: Member) => void
+ fetchOrganizationGroups: (organizationKey: string) => void,
+ addOrganizationMember: (organizationKey: string, member: Member) => void,
+ removeOrganizationMember: (organizationKey: string, member: Member) => void,
+ updateOrganizationMemberGroups: (member: Member, add: Array<string>, remove: Array<string>) => void,
};
export default class OrganizationMembers extends React.PureComponent {
@@ -46,6 +49,9 @@ export default class OrganizationMembers extends React.PureComponent {
if (!this.props.loading && notLoadedYet) {
this.handleSearchMembers();
}
+ if (this.props.organizationGroups.length <= 0) {
+ this.props.fetchOrganizationGroups(this.props.organization.key);
+ }
}
handleSearchMembers = (query?: string) => {
@@ -79,8 +85,10 @@ export default class OrganizationMembers extends React.PureComponent {
<UsersSearch onSearch={this.handleSearchMembers} />
<MembersList
members={members}
+ organizationGroups={this.props.organizationGroups}
organization={organization}
removeMember={this.removeMember}
+ updateMemberGroups={this.props.updateOrganizationMemberGroups}
/>
{status.total != null &&
<ListFooter
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembersContainer.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembersContainer.js
index 67b07e138df..ea70a4424bc 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembersContainer.js
+++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationMembersContainer.js
@@ -21,6 +21,7 @@ import { connect } from 'react-redux';
import OrganizationMembers from './OrganizationMembers';
import {
getOrganizationByKey,
+ getOrganizationGroupsByKey,
getOrganizationMembersLogins,
getUsersByLogins,
getOrganizationMembersState
@@ -28,8 +29,10 @@ import {
import {
fetchOrganizationMembers,
fetchMoreOrganizationMembers,
+ fetchOrganizationGroups,
addOrganizationMember,
- removeOrganizationMember
+ removeOrganizationMember,
+ updateOrganizationMemberGroups
} from '../actions';
const mapStateToProps = (state, ownProps) => {
@@ -39,6 +42,7 @@ const mapStateToProps = (state, ownProps) => {
memberLogins,
members: getUsersByLogins(state, memberLogins),
organization: getOrganizationByKey(state, organizationKey),
+ organizationGroups: getOrganizationGroupsByKey(state, organizationKey),
status: getOrganizationMembersState(state, organizationKey)
};
};
@@ -46,6 +50,8 @@ const mapStateToProps = (state, ownProps) => {
export default connect(mapStateToProps, {
fetchOrganizationMembers,
fetchMoreOrganizationMembers,
+ fetchOrganizationGroups,
addOrganizationMember,
- removeOrganizationMember
+ removeOrganizationMember,
+ updateOrganizationMemberGroups
})(OrganizationMembers);
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap
index 9832ec66cc2..02e1106b6b9 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap
+++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap
@@ -41,7 +41,7 @@ exports[`test should render actions and groups for admin 1`] = `
className="dropdown-toggle little-spacer-right"
data-toggle="dropdown">
<i
- className="icon-edit" />
+ className="icon-settings" />
<i
className="icon-dropdown" />
@@ -66,6 +66,27 @@ exports[`test should render actions and groups for admin 1`] = `
}
} />
</li>
+ <li
+ className="divider"
+ role="separator" />
+ <li>
+ <ManageMemberGroupsForm
+ member={
+ Object {
+ "avatar": "",
+ "groupCount": 3,
+ "login": "admin",
+ "name": "Admin Istrator",
+ }
+ }
+ organization={
+ Object {
+ "canAdmin": true,
+ "key": "foo",
+ "name": "Foo",
+ }
+ } />
+ </li>
</ul>
</div>
</td>
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/ManageMemberGroupsForm.js b/server/sonar-web/src/main/js/apps/organizations/components/forms/ManageMemberGroupsForm.js
new file mode 100644
index 00000000000..554b363dd35
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/ManageMemberGroupsForm.js
@@ -0,0 +1,155 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+// @flow
+import React from 'react';
+import Modal from 'react-modal';
+import { keyBy, pickBy } from 'lodash';
+import Checkbox from '../../../../components/controls/Checkbox';
+import { getUserGroups } from '../../../../api/users';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
+import type { Member } from '../../../../store/organizationsMembers/actions';
+import type { Organization, OrgGroup } from '../../../../store/organizations/duck';
+
+type Props = {
+ member: Member,
+ organization: Organization,
+ organizationGroups: Array<OrgGroup>,
+ updateMemberGroups: (member: Member, add: Array<string>, remove: Array<string>) => void
+};
+
+type State = {
+ open: boolean,
+ userGroups?: {},
+ loading?: boolean
+};
+
+export default class ManageMemberGroupsForm extends React.PureComponent {
+ props: Props;
+
+ state: State = {
+ open: false
+ };
+
+ openForm = () => {
+ if (!this.state.userGroups) {
+ this.loadUserGroups();
+ }
+ this.setState({ open: true });
+ };
+
+ closeForm = () => {
+ this.setState({ userGroups: undefined, open: false });
+ };
+
+ loadUserGroups = () => {
+ this.setState({ loading: true });
+ getUserGroups(this.props.member.login, this.props.organization.key).then(response => {
+ this.setState({ loading: false, userGroups: keyBy(response.groups, 'id') });
+ });
+ };
+
+ isGroupSelected = (groupId: string) => {
+ if (this.state.userGroups) {
+ const group = this.state.userGroups[groupId] || {};
+ if (group.status) {
+ return group.status === 'add';
+ } else {
+ return group.selected === true;
+ }
+ }
+ return false;
+ };
+
+ onCheck = (checked: boolean, groupId: string) => {
+ this.setState((prevState: State) => {
+ const userGroups = prevState.userGroups || {};
+ const group = userGroups[groupId] || {};
+ let status = '';
+ if (group.selected && !checked) {
+ status = 'remove';
+ } else if (!group.selected && checked) {
+ status = 'add';
+ }
+ return { userGroups: { ...userGroups, [groupId]: { ...group, status } } };
+ });
+ };
+
+ handleSubmit = (e: Object) => {
+ e.preventDefault();
+ this.props.updateMemberGroups(this.props.member,
+ Object.keys(pickBy(this.state.userGroups, group => group.status === 'add')),
+ Object.keys(pickBy(this.state.userGroups, group => group.status === 'remove'))
+ );
+ this.setState({ open: false });
+ };
+
+ renderModal() {
+ return (
+ <Modal
+ isOpen={true}
+ contentLabel="modal form"
+ className="modal"
+ overlayClassName="modal-overlay"
+ onRequestClose={this.closeForm}
+ >
+ <header className="modal-head">
+ <h2>{translate('organization.members.manage_groups')}</h2>
+ </header>
+ <form onSubmit={this.handleSubmit}>
+ <div className="modal-body">
+ <strong>
+ {translateWithParameters('organization.members.x_groups', this.props.member.name)}
+ </strong>{' '}{this.state.loading && <i className="spinner" />}
+ {!this.state.loading &&
+ <ul className="list-spaced">
+ {this.props.organizationGroups.map(group => (
+ <li className="capitalize" key={group.id}>
+ <Checkbox
+ id={group.id}
+ checked={this.isGroupSelected(group.id)}
+ onCheck={this.onCheck}
+ />
+ {' '}{group.name}
+ </li>
+ ))}
+ </ul>}
+ </div>
+ <footer className="modal-foot">
+ <div>
+ <button type="submit">{translate('save')}</button>
+ <button type="reset" className="button-link" onClick={this.closeForm}>
+ {translate('cancel')}
+ </button>
+ </div>
+ </footer>
+ </form>
+ </Modal>
+ );
+ }
+
+ render() {
+ return (
+ <a onClick={this.openForm} href="#">
+ {translate('organization.members.manage_groups')}
+ {this.state.open && this.renderModal()}
+ </a>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/AddMemberForm-test.js b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/AddMemberForm-test.js
index 7b8246f7b6a..403975ed4a1 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/AddMemberForm-test.js
+++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/AddMemberForm-test.js
@@ -36,4 +36,6 @@ it('should correctly handle user interactions', () => {
const wrapper = mount(<AddMemberForm memberLogins={memberLogins} addMember={addMember} />);
click(wrapper.find('button'));
expect(wrapper.state('open')).toBeTruthy();
+ wrapper.instance().closeForm();
+ expect(wrapper.state('open')).toBeFalsy();
});
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/ManageMemberGroupsForm-test.js b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/ManageMemberGroupsForm-test.js
new file mode 100644
index 00000000000..4d7eb28c9bb
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/ManageMemberGroupsForm-test.js
@@ -0,0 +1,122 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import React from 'react';
+import { shallow, mount } from 'enzyme';
+import { click, mockEvent } from '../../../../../helpers/testUtils';
+import ManageMemberGroupsForm from '../ManageMemberGroupsForm';
+
+const member = { login: 'admin', name: 'Admin Istrator', avatar: '', groupCount: 3 };
+const organization = { name: 'MyOrg', key: 'myorg' };
+const organizationGroups = [
+ {
+ id: '7',
+ name: 'professionals',
+ description: '',
+ membersCount: 12
+ },
+ {
+ id: '11',
+ name: 'pull-request-analysers',
+ description: 'Technical accounts',
+ membersCount: 3
+ },
+ {
+ id: '1',
+ name: 'sonar-administrators',
+ description: 'System administrators',
+ membersCount: 17
+ }
+];
+const userGroups = {
+ 11: { id: 11, name: 'pull-request-analysers', description: 'Technical accounts', selected: true }
+};
+const updateMemberGroups = jest.fn();
+
+const getMountedForm = function() {
+ const wrapper = mount(
+ <ManageMemberGroupsForm
+ member={member}
+ organization={organization}
+ organizationGroups={organizationGroups}
+ updateMemberGroups={updateMemberGroups}
+ />
+ );
+ const instance = wrapper.instance();
+ instance.loadUserGroups = jest.fn(() => {
+ instance.setState({ loading: false, userGroups });
+ });
+ return { wrapper, instance };
+};
+
+it('should render and open the modal', () => {
+ const wrapper = shallow(
+ <ManageMemberGroupsForm
+ member={member}
+ organization={organization}
+ organizationGroups={organizationGroups}
+ updateMemberGroups={updateMemberGroups}
+ />
+ );
+ expect(wrapper).toMatchSnapshot();
+ wrapper.setState({ open: true });
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('should correctly handle user interactions', () => {
+ const form = getMountedForm();
+ click(form.wrapper.find('a'));
+ expect(form.wrapper.state('open')).toBeTruthy();
+ expect(form.instance.loadUserGroups).toBeCalled();
+ expect(form.wrapper.state()).toMatchSnapshot();
+});
+
+it('should correctly select the groups', () => {
+ const form = getMountedForm();
+ form.instance.openForm();
+ expect(form.instance.isGroupSelected(11)).toBeTruthy();
+ expect(form.instance.isGroupSelected(7)).toBeFalsy();
+ form.instance.onCheck(false, 11);
+ form.instance.onCheck(true, 7);
+ expect(form.wrapper.state('userGroups')).toMatchSnapshot();
+ expect(form.instance.isGroupSelected(11)).toBeFalsy();
+ expect(form.instance.isGroupSelected(7)).toBeTruthy();
+});
+
+it('should correctly handle the submit event and close the modal', () => {
+ const form = getMountedForm();
+ form.instance.openForm();
+ form.instance.onCheck(false, 11);
+ form.instance.onCheck(true, 7);
+ form.instance.handleSubmit(mockEvent);
+ expect(updateMemberGroups.mock.calls).toMatchSnapshot();
+ expect(form.wrapper.state()).toMatchSnapshot();
+ form.instance.openForm();
+ expect(form.wrapper.state()).toMatchSnapshot();
+});
+
+it('should reset the selected groups when the modal is closed', () => {
+ const form = getMountedForm();
+ form.instance.openForm();
+ form.instance.onCheck(false, 11);
+ form.instance.onCheck(true, 7);
+ expect(form.wrapper.state()).toMatchSnapshot();
+ form.instance.closeForm();
+ expect(form.wrapper.state()).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/RemoveMemberForm-test.js b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/RemoveMemberForm-test.js
index 03df0ec7df4..e8f813e2a0d 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/RemoveMemberForm-test.js
+++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/RemoveMemberForm-test.js
@@ -19,10 +19,10 @@
*/
import React from 'react';
import { shallow, mount } from 'enzyme';
-import { click } from '../../../../../helpers/testUtils';
+import { click, mockEvent } from '../../../../../helpers/testUtils';
import RemoveMemberForm from '../RemoveMemberForm';
-const member = {};
+const member = { login: 'admin', name: 'Admin Istrator', avatar: '', groupCount: 3 };
const removeMember = jest.fn();
const organization = { name: 'MyOrg' };
@@ -39,6 +39,11 @@ it('should correctly handle user interactions', () => {
const wrapper = mount(
<RemoveMemberForm member={member} removeMember={removeMember} organization={organization} />
);
+ const instance = wrapper.instance();
click(wrapper.find('a'));
expect(wrapper.state('open')).toBeTruthy();
+ instance.handleSubmit(mockEvent);
+ expect(removeMember.mock.calls).toMatchSnapshot();
+ instance.closeForm();
+ expect(wrapper.state('open')).toBeFalsy();
});
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/ManageMemberGroupsForm-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/ManageMemberGroupsForm-test.js.snap
new file mode 100644
index 00000000000..2e069febb73
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/ManageMemberGroupsForm-test.js.snap
@@ -0,0 +1,205 @@
+exports[`test should correctly handle the submit event and close the modal 1`] = `
+Array [
+ Array [
+ Object {
+ "avatar": "",
+ "groupCount": 3,
+ "login": "admin",
+ "name": "Admin Istrator",
+ },
+ Array [
+ "7",
+ ],
+ Array [
+ "11",
+ ],
+ ],
+]
+`;
+
+exports[`test should correctly handle the submit event and close the modal 2`] = `
+Object {
+ "loading": false,
+ "open": false,
+ "userGroups": Object {
+ "11": Object {
+ "description": "Technical accounts",
+ "id": 11,
+ "name": "pull-request-analysers",
+ "selected": true,
+ "status": "remove",
+ },
+ "7": Object {
+ "status": "add",
+ },
+ },
+}
+`;
+
+exports[`test should correctly handle the submit event and close the modal 3`] = `
+Object {
+ "loading": false,
+ "open": true,
+ "userGroups": Object {
+ "11": Object {
+ "description": "Technical accounts",
+ "id": 11,
+ "name": "pull-request-analysers",
+ "selected": true,
+ "status": "remove",
+ },
+ "7": Object {
+ "status": "add",
+ },
+ },
+}
+`;
+
+exports[`test should correctly handle user interactions 1`] = `
+Object {
+ "loading": false,
+ "open": true,
+ "userGroups": Object {
+ "11": Object {
+ "description": "Technical accounts",
+ "id": 11,
+ "name": "pull-request-analysers",
+ "selected": true,
+ },
+ },
+}
+`;
+
+exports[`test should correctly select the groups 1`] = `
+Object {
+ "11": Object {
+ "description": "Technical accounts",
+ "id": 11,
+ "name": "pull-request-analysers",
+ "selected": true,
+ "status": "remove",
+ },
+ "7": Object {
+ "status": "add",
+ },
+}
+`;
+
+exports[`test should render and open the modal 1`] = `
+<a
+ href="#"
+ onClick={[Function]}>
+ organization.members.manage_groups
+</a>
+`;
+
+exports[`test should render and open the modal 2`] = `
+<a
+ href="#"
+ onClick={[Function]}>
+ organization.members.manage_groups
+ <Modal
+ ariaHideApp={true}
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="modal form"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}>
+ <header
+ className="modal-head">
+ <h2>
+ organization.members.manage_groups
+ </h2>
+ </header>
+ <form
+ onSubmit={[Function]}>
+ <div
+ className="modal-body">
+ <strong>
+ organization.members.x_groups.Admin Istrator
+ </strong>
+
+ <ul
+ className="list-spaced">
+ <li
+ className="capitalize">
+ <Checkbox
+ checked={false}
+ id="7"
+ onCheck={[Function]}
+ thirdState={false} />
+
+ professionals
+ </li>
+ <li
+ className="capitalize">
+ <Checkbox
+ checked={false}
+ id="11"
+ onCheck={[Function]}
+ thirdState={false} />
+
+ pull-request-analysers
+ </li>
+ <li
+ className="capitalize">
+ <Checkbox
+ checked={false}
+ id="1"
+ onCheck={[Function]}
+ thirdState={false} />
+
+ sonar-administrators
+ </li>
+ </ul>
+ </div>
+ <footer
+ className="modal-foot">
+ <div>
+ <button
+ type="submit">
+ save
+ </button>
+ <button
+ className="button-link"
+ onClick={[Function]}
+ type="reset">
+ cancel
+ </button>
+ </div>
+ </footer>
+ </form>
+ </Modal>
+</a>
+`;
+
+exports[`test should reset the selected groups when the modal is closed 1`] = `
+Object {
+ "loading": false,
+ "open": true,
+ "userGroups": Object {
+ "11": Object {
+ "description": "Technical accounts",
+ "id": 11,
+ "name": "pull-request-analysers",
+ "selected": true,
+ "status": "remove",
+ },
+ "7": Object {
+ "status": "add",
+ },
+ },
+}
+`;
+
+exports[`test should reset the selected groups when the modal is closed 2`] = `
+Object {
+ "loading": false,
+ "open": false,
+ "userGroups": undefined,
+}
+`;
diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/RemoveMemberForm-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/RemoveMemberForm-test.js.snap
index da6b474108b..e27609e4b4d 100644
--- a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/RemoveMemberForm-test.js.snap
+++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/RemoveMemberForm-test.js.snap
@@ -1,3 +1,16 @@
+exports[`test should correctly handle user interactions 1`] = `
+Array [
+ Array [
+ Object {
+ "avatar": "",
+ "groupCount": 3,
+ "login": "admin",
+ "name": "Admin Istrator",
+ },
+ ],
+]
+`;
+
exports[`test should render and open the modal 1`] = `
<a
href="#"
@@ -32,7 +45,7 @@ exports[`test should render and open the modal 2`] = `
onSubmit={[Function]}>
<div
className="modal-body">
- organization.members.remove_x..MyOrg
+ organization.members.remove_x.Admin Istrator.MyOrg
<ul
className="list-styled">
<li>
diff --git a/server/sonar-web/src/main/js/components/controls/Checkbox.js b/server/sonar-web/src/main/js/components/controls/Checkbox.js
index e11fe009f75..40554f1027d 100644
--- a/server/sonar-web/src/main/js/components/controls/Checkbox.js
+++ b/server/sonar-web/src/main/js/components/controls/Checkbox.js
@@ -39,7 +39,7 @@ export default class Checkbox extends React.Component {
handleClick(e) {
e.preventDefault();
e.target.blur();
- this.props.onCheck(!this.props.checked);
+ this.props.onCheck(!this.props.checked, this.props.id);
}
render() {
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.js b/server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.js
index dcb782f37cb..c6692cc751e 100644
--- a/server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.js
+++ b/server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.js
@@ -48,5 +48,12 @@ it('should call onCheck', () => {
const onCheck = jest.fn();
const checkbox = shallow(<Checkbox checked={false} onCheck={onCheck} />);
click(checkbox);
- expect(onCheck).toBeCalledWith(true);
+ expect(onCheck).toBeCalledWith(true, undefined);
+});
+
+it('should call onCheck with id as second parameter', () => {
+ const onCheck = jest.fn();
+ const checkbox = shallow(<Checkbox id="foo" checked={false} onCheck={onCheck} />);
+ click(checkbox);
+ expect(onCheck).toBeCalledWith(true, 'foo');
});
diff --git a/server/sonar-web/src/main/js/helpers/testUtils.js b/server/sonar-web/src/main/js/helpers/testUtils.js
index 358df804d4f..b426403453f 100644
--- a/server/sonar-web/src/main/js/helpers/testUtils.js
+++ b/server/sonar-web/src/main/js/helpers/testUtils.js
@@ -17,13 +17,15 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+export const mockEvent = {
+ target: { blur() {} },
+ currentTarget: { blur() {} },
+ preventDefault() {},
+ stopPropagation() {}
+};
+
export const click = element => {
- return element.simulate('click', {
- target: { blur() {} },
- currentTarget: { blur() {} },
- preventDefault() {},
- stopPropagation() {}
- });
+ return element.simulate('click', mockEvent);
};
export const submit = element => {
diff --git a/server/sonar-web/src/main/js/store/organizations/__tests__/__snapshots__/duck-test.js.snap b/server/sonar-web/src/main/js/store/organizations/__tests__/__snapshots__/duck-test.js.snap
index 7225fb41f88..1b9f5405edd 100644
--- a/server/sonar-web/src/main/js/store/organizations/__tests__/__snapshots__/duck-test.js.snap
+++ b/server/sonar-web/src/main/js/store/organizations/__tests__/__snapshots__/duck-test.js.snap
@@ -1,6 +1,7 @@
exports[`Reducer should have initial state 1`] = `
Object {
"byKey": Object {},
+ "groups": Object {},
"my": Array [],
}
`;
@@ -17,6 +18,7 @@ Object {
"name": "Foo",
},
},
+ "groups": Object {},
"my": Array [],
}
`;
@@ -33,6 +35,7 @@ Object {
"name": "Qwe",
},
},
+ "groups": Object {},
"my": Array [],
}
`;
diff --git a/server/sonar-web/src/main/js/store/organizations/duck.js b/server/sonar-web/src/main/js/store/organizations/duck.js
index 40d815ce856..8c56380aa42 100644
--- a/server/sonar-web/src/main/js/store/organizations/duck.js
+++ b/server/sonar-web/src/main/js/store/organizations/duck.js
@@ -32,6 +32,13 @@ export type Organization = {
url?: string
};
+export type OrgGroup = {
+ id: string,
+ name: string,
+ description: string,
+ membersCount: number
+};
+
type ReceiveOrganizationsAction = {
type: 'RECEIVE_ORGANIZATIONS',
organizations: Array<Organization>
@@ -42,6 +49,12 @@ type ReceiveMyOrganizationsAction = {
organizations: Array<Organization>
};
+type ReceiveOrganizationGroups = {
+ type: 'RECEIVE_ORGANIZATION_GROUPS',
+ key: string,
+ groups: Array<OrgGroup>
+};
+
type CreateOrganizationAction = {
type: 'CREATE_ORGANIZATION',
organization: Organization
@@ -61,6 +74,7 @@ type DeleteOrganizationAction = {
type Action =
| ReceiveOrganizationsAction
| ReceiveMyOrganizationsAction
+ | ReceiveOrganizationGroups
| CreateOrganizationAction
| UpdateOrganizationAction
| DeleteOrganizationAction;
@@ -69,11 +83,16 @@ type ByKeyState = {
[key: string]: Organization
};
+type GroupsState = {
+ [key: string]: Array<OrgGroup>
+};
+
type MyState = Array<string>;
type State = {
byKey: ByKeyState,
- my: MyState
+ my: MyState,
+ groups: GroupsState
};
export const receiveOrganizations = (
@@ -90,6 +109,12 @@ export const receiveMyOrganizations = (
organizations
});
+export const receiveOrganizationGroups = (key: string, groups: Array<OrgGroup>): receiveOrganizationGroups => ({
+ type: 'RECEIVE_ORGANIZATION_GROUPS',
+ key,
+ groups
+});
+
export const createOrganization = (organization: Organization): CreateOrganizationAction => ({
type: 'CREATE_ORGANIZATION',
organization
@@ -153,10 +178,21 @@ const my = (state: MyState = [], action: Action) => {
}
};
-export default combineReducers({ byKey, my });
+const groups = (state: GroupsState = {}, action: Action) => {
+ switch (action.type) {
+ case 'RECEIVE_ORGANIZATION_GROUPS':
+ return { ...state, [action.key]: action.groups };
+ default:
+ return state;
+ }
+};
+
+export default combineReducers({ byKey, my, groups });
export const getOrganizationByKey = (state: State, key: string): Organization => state.byKey[key];
+export const getOrganizationGroupsByKey = (state: State, key: string): Array<OrgGroup> => state.groups[key] || [];
+
export const getMyOrganizations = (state: State): Array<Organization> =>
state.my.map(key => getOrganizationByKey(state, key));
diff --git a/server/sonar-web/src/main/js/store/rootReducer.js b/server/sonar-web/src/main/js/store/rootReducer.js
index cac6ee072ea..54a35fec2da 100644
--- a/server/sonar-web/src/main/js/store/rootReducer.js
+++ b/server/sonar-web/src/main/js/store/rootReducer.js
@@ -113,6 +113,9 @@ export const getNotificationPerProjectTypes = state =>
export const getOrganizationByKey = (state, key) =>
fromOrganizations.getOrganizationByKey(state.organizations, key);
+export const getOrganizationGroupsByKey = (state, key) =>
+ fromOrganizations.getOrganizationGroupsByKey(state.organizations, key);
+
export const getMyOrganizations = state =>
fromOrganizations.getMyOrganizations(state.organizations);
diff --git a/server/sonar-web/src/main/js/store/users/actions.js b/server/sonar-web/src/main/js/store/users/actions.js
index d5e0616681d..a0331d053f1 100644
--- a/server/sonar-web/src/main/js/store/users/actions.js
+++ b/server/sonar-web/src/main/js/store/users/actions.js
@@ -20,11 +20,17 @@
import { getCurrentUser } from '../../api/users';
export const RECEIVE_CURRENT_USER = 'RECEIVE_CURRENT_USER';
+export const RECEIVE_USER = 'RECEIVE_USER';
export const receiveCurrentUser = user => ({
type: RECEIVE_CURRENT_USER,
user
});
+export const receiveUser = user => ({
+ type: RECEIVE_USER,
+ user
+});
+
export const fetchCurrentUser = () =>
dispatch => getCurrentUser().then(user => dispatch(receiveCurrentUser(user)));
diff --git a/server/sonar-web/src/main/js/store/users/reducer.js b/server/sonar-web/src/main/js/store/users/reducer.js
index 5a943db98f7..836f74a9697 100644
--- a/server/sonar-web/src/main/js/store/users/reducer.js
+++ b/server/sonar-web/src/main/js/store/users/reducer.js
@@ -19,12 +19,13 @@
*/
import { combineReducers } from 'redux';
import { uniq, keyBy } from 'lodash';
-import { RECEIVE_CURRENT_USER } from './actions';
+import { RECEIVE_CURRENT_USER, RECEIVE_USER } from './actions';
import { actions as membersActions } from '../organizationsMembers/actions';
const usersByLogin = (state = {}, action = {}) => {
switch (action.type) {
case RECEIVE_CURRENT_USER:
+ case RECEIVE_USER:
return { ...state, [action.user.login]: action.user };
case membersActions.RECEIVE_MEMBERS:
case membersActions.RECEIVE_MORE_MEMBERS:
@@ -39,6 +40,7 @@ const usersByLogin = (state = {}, action = {}) => {
const userLogins = (state = [], action = {}) => {
switch (action.type) {
case RECEIVE_CURRENT_USER:
+ case RECEIVE_USER:
return uniq([...state, action.user.login]);
case membersActions.RECEIVE_MEMBERS:
case membersActions.RECEIVE_MORE_MEMBERS:
diff --git a/server/sonar-web/src/main/less/init/lists.less b/server/sonar-web/src/main/less/init/lists.less
index 73fc1d6f615..f62499995ac 100644
--- a/server/sonar-web/src/main/less/init/lists.less
+++ b/server/sonar-web/src/main/less/init/lists.less
@@ -53,6 +53,16 @@ ol, ul {
padding-left: 5px;
}
+// Spaced list
+.list-spaced {
+ margin-bottom: 10px;
+ list-style: none;
+
+ & > li {
+ margin-top: 10px;
+ }
+}
+
// Definition lists
diff --git a/server/sonar-web/src/main/less/init/misc.less b/server/sonar-web/src/main/less/init/misc.less
index ab82b810c40..86c49ad83e7 100644
--- a/server/sonar-web/src/main/less/init/misc.less
+++ b/server/sonar-web/src/main/less/init/misc.less
@@ -144,6 +144,10 @@ td.big-spacer-top { padding-top: 16px; }
}
}
+.capitalize {
+ text-transform: capitalize;
+}
+
// Background Color