diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-03-29 11:11:56 +0200 |
---|---|---|
committer | Grégoire Aubert <gregaubert@users.noreply.github.com> | 2017-03-31 10:29:27 +0200 |
commit | 19adabdc666a5513b014776ffc283a911a6b2316 (patch) | |
tree | add80ced12cbd0362591d41eebc1be3083548b00 /server/sonar-web/src/main | |
parent | 76ad0222a481f307474b2990f264780f42cf3627 (diff) | |
download | sonarqube-19adabdc666a5513b014776ffc283a911a6b2316.tar.gz sonarqube-19adabdc666a5513b014776ffc283a911a6b2316.zip |
SONAR-8994 Add groups management in members view
Diffstat (limited to 'server/sonar-web/src/main')
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 |