diff options
26 files changed, 966 insertions, 36 deletions
diff --git a/server/sonar-web/src/main/js/api/organizations.js b/server/sonar-web/src/main/js/api/organizations.js index 0723fb0c258..41f3c014b9c 100644 --- a/server/sonar-web/src/main/js/api/organizations.js +++ b/server/sonar-web/src/main/js/api/organizations.js @@ -56,7 +56,7 @@ export const updateOrganization = (key: string, changes: {}) => export const deleteOrganization = (key: string) => post('/api/organizations/delete', { key }); export const searchMembers = ( - data: { organizations?: string, p?: number, ps?: number, q?: string, selected?: string } + data: { organization?: string, p?: number, ps?: number, q?: string, selected?: string } ) => getJSON('/api/organizations/search_members', data); export const addMember = (data: { login: string, organization: string }) => diff --git a/server/sonar-web/src/main/js/api/users.js b/server/sonar-web/src/main/js/api/users.js index a661bcace4e..2014d391454 100644 --- a/server/sonar-web/src/main/js/api/users.js +++ b/server/sonar-web/src/main/js/api/users.js @@ -40,8 +40,11 @@ export function getIdentityProviders() { return getJSON(url); } -export function searchUsers(query) { +export function searchUsers(query, pageSize) { const url = '/api/users/search'; const data = { q: query }; + if (pageSize != null) { + data.ps = pageSize; + } return getJSON(url, data); } 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 916a9d7e17a..f21d6bb775f 100644 --- a/server/sonar-web/src/main/js/apps/organizations/actions.js +++ b/server/sonar-web/src/main/js/apps/organizations/actions.js @@ -26,6 +26,7 @@ import { getOrganizationMembersState } from '../../store/rootReducer'; import { addGlobalSuccessMessage } from '../../store/globalMessages/duck'; import { translate, translateWithParameters } from '../../helpers/l10n'; import type { Organization } from '../../store/organizations/duck'; +import type { Member } from '../../store/organizationsMembers/actions'; const PAGE_SIZE = 50; @@ -97,7 +98,7 @@ const fetchMembers = ( ) => { dispatch(membersActions.updateState(key, { loading: true })); const data: Object = { - organizations: key, + organization: key, ps: PAGE_SIZE }; if (page != null) { @@ -131,3 +132,11 @@ export const fetchMoreOrganizationMembers = (key: string, query?: string) => query, 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); + dispatch(membersActions.removeMember(key, member)); + }); +}; 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 156f8262e27..a5744dfd253 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 @@ -25,7 +25,7 @@ import type { Organization } from '../../../store/organizations/duck'; type Props = { members: Array<Member>, - organization?: Organization + organization?: Organization, }; export default class MembersList extends React.PureComponent { @@ -36,7 +36,11 @@ export default class MembersList extends React.PureComponent { <table className="data zebra"> <tbody> {this.props.members.map(member => ( - <MembersListItem key={member.login} member={member} organization={this.props.organization} /> + <MembersListItem + key={member.login} + member={member} + organization={this.props.organization} + /> ))} </tbody> </table> 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 b3e252c0669..cf87c5cc85e 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 @@ -21,6 +21,7 @@ import React from 'react'; import PageHeader from './PageHeader'; 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'; @@ -28,10 +29,12 @@ import type { Member } from '../../../store/organizationsMembers/actions'; type Props = { members: Array<Member>, + memberLogins: Array<string>, 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 }; export default class OrganizationMembers extends React.PureComponent { @@ -52,17 +55,27 @@ export default class OrganizationMembers extends React.PureComponent { this.props.fetchMoreOrganizationMembers(this.props.organization.key, this.props.status.query); }; - addMember() { - // TODO Not yet implemented - } + addMember = (member: Member) => { + this.props.addOrganizationMember(this.props.organization.key, member); + }; render() { const { organization, status, members } = this.props; return ( <div className="page page-limited"> - <PageHeader loading={status.loading} total={status.total} /> + <PageHeader loading={status.loading} total={status.total}> + {organization.canAdmin && + <div className="page-actions"> + <div className="button-group"> + <AddMemberForm memberLogins={this.props.memberLogins} addMember={this.addMember} /> + </div> + </div>} + </PageHeader> <UsersSearch onSearch={this.handleSearchMembers} /> - <MembersList members={members} organization={organization} /> + <MembersList + members={members} + organization={organization} + /> {status.total != null && <ListFooter count={members.length} 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 27dd084b063..abea8ce8552 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 @@ -25,18 +25,25 @@ import { getUsersByLogins, getOrganizationMembersState } from '../../../store/rootReducer'; -import { fetchOrganizationMembers, fetchMoreOrganizationMembers } from '../actions'; +import { + fetchOrganizationMembers, + fetchMoreOrganizationMembers, + addOrganizationMember +} from '../actions'; const mapStateToProps = (state, ownProps) => { const { organizationKey } = ownProps.params; const memberLogins = getOrganizationMembersLogins(state, organizationKey); return { + memberLogins, members: getUsersByLogins(state, memberLogins), organization: getOrganizationByKey(state, organizationKey), status: getOrganizationMembersState(state, organizationKey) }; }; -export default connect(mapStateToProps, { fetchOrganizationMembers, fetchMoreOrganizationMembers })( - OrganizationMembers -); +export default connect(mapStateToProps, { + fetchOrganizationMembers, + fetchMoreOrganizationMembers, + addOrganizationMember +})(OrganizationMembers); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationMembers-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationMembers-test.js.snap index 3af82c13471..87ab6b15a84 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationMembers-test.js.snap +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationMembers-test.js.snap @@ -41,7 +41,16 @@ exports[`test should render actions for admin 1`] = ` className="page page-limited"> <PageHeader loading={true} - total={2} /> + total={2}> + <div + className="page-actions"> + <div + className="button-group"> + <AddMemberForm + addMember={[Function]} /> + </div> + </div> + </PageHeader> <UsersSearch onSearch={[Function]} /> <MembersList diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js b/server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js new file mode 100644 index 00000000000..3ed2825cde5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/AddMemberForm.js @@ -0,0 +1,110 @@ +/* + * 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 UsersSelectSearch from '../../../users/components/UsersSelectSearch'; +import { searchUsers } from '../../../../api/users'; +import { translate } from '../../../../helpers/l10n'; +import type { Member } from '../../../../store/organizationsMembers/actions'; + +type Props = { + memberLogins: Array<string>, + addMember: (member: Member) => void +}; + +type State = { + open: boolean, + selectedMember?: Member +}; + +export default class AddMemberForm extends React.PureComponent { + props: Props; + + state: State = { + open: false + }; + + openForm = () => { + this.setState({ open: true }); + }; + + closeForm = () => { + this.setState({ open: false, selectedMember: undefined }); + }; + + handleSubmit = (e: Object) => { + e.preventDefault(); + if (this.state.selectedMember) { + this.props.addMember(this.state.selectedMember); + this.closeForm(); + } + }; + + selectedMemberChange = (member: Member) => { + this.setState({ selectedMember: member }); + }; + + renderModal() { + return ( + <Modal + isOpen={true} + contentLabel="modal form" + className="modal" + overlayClassName="modal-overlay" + onRequestClose={this.closeForm} + > + <header className="modal-head"> + <h2>{translate('users.add')}</h2> + </header> + <form onSubmit={this.handleSubmit}> + <div className="modal-body"> + <div className="modal-large-field"> + <label>{translate('users.search_description')}</label> + <UsersSelectSearch + selectedUser={this.state.selectedMember} + excludedUsers={this.props.memberLogins} + searchUsers={searchUsers} + handleValueChange={this.selectedMemberChange} + /> + </div> + </div> + <footer className="modal-foot"> + <div> + <button type="submit">{translate('organization.members.add_to_members')}</button> + <button type="reset" className="button-link" onClick={this.closeForm}> + {translate('cancel')} + </button> + </div> + </footer> + </form> + </Modal> + ); + } + + render() { + return ( + <button onClick={this.openForm}> + {translate('organization.members.add')} + {this.state.open && this.renderModal()} + </button> + ); + } +} 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 new file mode 100644 index 00000000000..7b8246f7b6a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/AddMemberForm-test.js @@ -0,0 +1,39 @@ +/* + * 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 } from '../../../../../helpers/testUtils'; +import AddMemberForm from '../AddMemberForm'; + +const memberLogins = ['admin']; +const addMember = jest.fn(); + +it('should render and open the modal', () => { + const wrapper = shallow(<AddMemberForm memberLogins={memberLogins} addMember={addMember} />); + expect(wrapper).toMatchSnapshot(); + wrapper.setState({ open: true }); + expect(wrapper).toMatchSnapshot(); +}); + +it('should correctly handle user interactions', () => { + const wrapper = mount(<AddMemberForm memberLogins={memberLogins} addMember={addMember} />); + click(wrapper.find('button')); + expect(wrapper.state('open')).toBeTruthy(); +}); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/AddMemberForm-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/AddMemberForm-test.js.snap new file mode 100644 index 00000000000..8cae5645817 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizations/components/forms/__tests__/__snapshots__/AddMemberForm-test.js.snap @@ -0,0 +1,66 @@ +exports[`test should render and open the modal 1`] = ` +<button + onClick={[Function]}> + organization.members.add +</button> +`; + +exports[`test should render and open the modal 2`] = ` +<button + onClick={[Function]}> + organization.members.add + <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> + users.add + </h2> + </header> + <form + onSubmit={[Function]}> + <div + className="modal-body"> + <div + className="modal-large-field"> + <label> + users.search_description + </label> + <UsersSelectSearch + excludedUsers={ + Array [ + "admin", + ] + } + handleValueChange={[Function]} + searchUsers={[Function]} /> + </div> + </div> + <footer + className="modal-foot"> + <div> + <button + type="submit"> + organization.members.add_to_members + </button> + <button + className="button-link" + onClick={[Function]} + type="reset"> + cancel + </button> + </div> + </footer> + </form> + </Modal> +</button> +`; diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js b/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js index 5c17f90f8cf..1b8290cd35d 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js +++ b/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js @@ -56,7 +56,7 @@ export default class UsersSearch extends React.PureComponent { render() { const { query } = this.state; const inputClassName = classNames('search-box-input', { - touched: query != null && query !== '' && query.length < 2 + touched: query != null && query.length === 1 }); return ( <div className="panel panel-vertical bordered-bottom spacer-bottom"> diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.css b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.css new file mode 100644 index 00000000000..c8780833809 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.css @@ -0,0 +1,13 @@ +.Select-big .Select-control { + padding-top: 4px; + padding-bottom: 4px; +} + +.Select-big .Select-placeholder { + margin-top: 4px; + margin-bottom: 4px; +} + +.Select-big .Select-value-label { + margin-top: 5px; +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js new file mode 100644 index 00000000000..5026720f2b2 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js @@ -0,0 +1,102 @@ +/* + * 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 Select from 'react-select'; +import { debounce } from 'lodash'; +import UsersSelectSearchOption from './UsersSelectSearchOption'; +import UsersSelectSearchValue from './UsersSelectSearchValue'; +import './UsersSelectSearch.css'; + +export type Option = { + login: string, + name: string, + email?: string, + avatar?: string, + groupCount?: number +}; + +type Props = { + selectedUser?: Option, + excludedUsers: Array<string>, + searchUsers: (string, number) => Promise<*>, + handleValueChange: (Option) => void +}; + +type State = { + searchResult: Array<Option>, + isLoading: boolean, + search: string +}; + +const LIST_SIZE = 10; + +export default class UsersSelectSearch extends React.PureComponent { + props: Props; + state: State; + + constructor(props: Props) { + super(props); + this.handleSearch = debounce(this.handleSearch); + this.state = { searchResult: [], isLoading: false, search: '' }; + } + + componentDidMount() { + this.handleSearch(this.state.search); + } + + componentWillReceiveProps(nextProps: Props) { + if (this.props.excludedUsers !== nextProps.excludedUsers) { + this.handleSearch(this.state.search); + } + } + + filterSearchResult = ({ users }: { users: Array<Option> }) => + users.filter(user => !this.props.excludedUsers.includes(user.login)).slice(0, LIST_SIZE); + + handleSearch = (search: string) => { + this.setState({ isLoading: true, search }); + this.props.searchUsers(search, Math.min(this.props.excludedUsers.length + LIST_SIZE, 500)) + .then(this.filterSearchResult) + .then(searchResult => { + this.setState({ isLoading: false, searchResult }); + }); + }; + + render() { + return ( + <Select + className="Select-big" + options={this.state.searchResult} + isLoading={this.state.isLoading} + optionComponent={UsersSelectSearchOption} + valueComponent={UsersSelectSearchValue} + onChange={this.props.handleValueChange} + onInputChange={this.handleSearch} + value={this.props.selectedUser} + placeholder="" + labelKey="name" + valueKey="login" + clearable={false} + searchable={true} + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js new file mode 100644 index 00000000000..1861cb83d0c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js @@ -0,0 +1,74 @@ +/* + * 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 Avatar from '../../../components/ui/Avatar'; +import type { Option } from './UsersSelectSearch'; + +type Props = { + option: Option, + children?: Element | Text, + className?: string, + isFocused?: boolean, + onFocus: (Option, MouseEvent) => void, + onSelect: (Option, MouseEvent) => void +}; + +const AVATAR_SIZE: number = 20; + +export default class UsersSelectSearchOption extends React.PureComponent { + props: Props; + + handleMouseDown = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + this.props.onSelect(this.props.option, event); + }; + + handleMouseEnter = (event: MouseEvent) => { + this.props.onFocus(this.props.option, event); + }; + + handleMouseMove = (event: MouseEvent) => { + if (this.props.isFocused) { + return; + } + this.props.onFocus(this.props.option, event); + }; + + render() { + const user = this.props.option; + return ( + <div + className={this.props.className} + onMouseDown={this.handleMouseDown} + onMouseEnter={this.handleMouseEnter} + onMouseMove={this.handleMouseMove} + title={user.name} + > + <div className="little-spacer-bottom little-spacer-top"> + <Avatar hash={user.avatar} email={user.email} size={AVATAR_SIZE} /> + <strong className="spacer-left">{user.login}</strong> + <span className="note little-spacer-left">{this.props.children}</span> + </div> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js new file mode 100644 index 00000000000..b0939f127a9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js @@ -0,0 +1,48 @@ +/* + * 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 Avatar from '../../../components/ui/Avatar'; +import type { Option } from './UsersSelectSearch'; + +type Props = { + value: Option, + children?: Element | Text +}; + +const AVATAR_SIZE: number = 20; + +export default class UsersSelectSearchValue extends React.PureComponent { + props: Props; + + render() { + const user = this.props.value; + return ( + <div className="Select-value" title={user ? user.name : ''}> + {user && user.login && + <div className="Select-value-label"> + <Avatar hash={user.avatar} email={user.email} size={AVATAR_SIZE} /> + <strong className="spacer-left">{user.login}</strong> + <span className="note little-spacer-left">{this.props.children}</span> + </div>} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js new file mode 100644 index 00000000000..cfc0481145c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js @@ -0,0 +1,54 @@ +/* + * 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 } from 'enzyme'; +import UsersSelectSearch from '../UsersSelectSearch'; + +const selectedUser = { + login: 'admin', + name: 'Administrator', + avatar: '7daf6c79d4802916d83f6266e24850af' +}; +const users = [ + { login: 'admin', name: 'Administrator', email: 'admin@admin.ch' }, + { login: 'test', name: 'Tester', email: 'tester@testing.ch' }, + { login: 'foo', name: 'Foo Bar', email: 'foo@bar.ch' } +]; +const excludedUsers = ['admin']; +const onSearch = jest.fn(() => { + return Promise.resolve(users); +}); +const onChange = jest.fn(); + +it('should render correctly', () => { + const wrapper = shallow( + <UsersSelectSearch + selectedUser={selectedUser} + excludedUsers={excludedUsers} + isLoading={false} + handleValueChange={onChange} + searchUsers={onSearch} + /> + ); + expect(wrapper).toMatchSnapshot(); + const searchResult = wrapper.instance().filterSearchResult({ users }); + expect(searchResult).toMatchSnapshot(); + expect(wrapper.setState({ searchResult })).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js new file mode 100644 index 00000000000..3023f73918c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js @@ -0,0 +1,48 @@ +/* + * 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 } from 'enzyme'; +import UsersSelectSearchOption from '../UsersSelectSearchOption'; + +const user = { + login: 'admin', + name: 'Administrator', + avatar: '7daf6c79d4802916d83f6266e24850af' +}; + +const user2 = { + login: 'admin', + name: 'Administrator', + email: 'admin@admin.ch' +}; + +it('should render correctly without all parameters', () => { + const wrapper = shallow( + <UsersSelectSearchOption option={user}>{user.name}</UsersSelectSearchOption> + ); + expect(wrapper).toMatchSnapshot(); +}); + +it('should render correctly with email instead of hash', () => { + const wrapper = shallow( + <UsersSelectSearchOption option={user2}>{user.name}</UsersSelectSearchOption> + ); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js new file mode 100644 index 00000000000..357365a0826 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js @@ -0,0 +1,53 @@ +/* + * 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 } from 'enzyme'; +import UsersSelectSearchValue from '../UsersSelectSearchValue'; + +const user = { + login: 'admin', + name: 'Administrator', + avatar: '7daf6c79d4802916d83f6266e24850af' +}; + +const user2 = { + login: 'admin', + name: 'Administrator', + email: 'admin@admin.ch' +}; + +it('should render correctly with a user', () => { + const wrapper = shallow( + <UsersSelectSearchValue value={user}>{user.name}</UsersSelectSearchValue> + ); + expect(wrapper).toMatchSnapshot(); +}); + +it('should render correctly with email instead of hash', () => { + const wrapper = shallow( + <UsersSelectSearchValue value={user2}>{user2.name}</UsersSelectSearchValue> + ); + expect(wrapper).toMatchSnapshot(); +}); + +it('should render correctly without value', () => { + const wrapper = shallow(<UsersSelectSearchValue />); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap new file mode 100644 index 00000000000..e4f6d80595e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap @@ -0,0 +1,131 @@ +exports[`test should render correctly 1`] = ` +<Select + addLabelText="Add \"{label}\"?" + arrowRenderer={[Function]} + autosize={true} + backspaceRemoves={true} + backspaceToRemoveMessage="Press backspace to remove {label}" + className="Select-big" + clearAllText="Clear all" + clearValueText="Clear value" + clearable={false} + delimiter="," + disabled={false} + escapeClearsValue={true} + filterOptions={[Function]} + ignoreAccents={true} + ignoreCase={true} + inputProps={Object {}} + isLoading={false} + joinValues={false} + labelKey="name" + matchPos="any" + matchProp="any" + menuBuffer={0} + menuRenderer={[Function]} + multi={false} + noResultsText="No results found" + onBlurResetsInput={true} + onChange={[Function]} + onCloseResetsInput={true} + onInputChange={[Function]} + openAfterFocus={false} + optionComponent={[Function]} + options={Array []} + pageSize={5} + placeholder="" + required={false} + scrollMenuIntoView={true} + searchable={true} + simpleValue={false} + tabSelectsValue={true} + value={ + Object { + "avatar": "7daf6c79d4802916d83f6266e24850af", + "login": "admin", + "name": "Administrator", + } + } + valueComponent={[Function]} + valueKey="login" /> +`; + +exports[`test should render correctly 2`] = ` +Array [ + Object { + "email": "tester@testing.ch", + "login": "test", + "name": "Tester", + }, + Object { + "email": "foo@bar.ch", + "login": "foo", + "name": "Foo Bar", + }, +] +`; + +exports[`test should render correctly 3`] = ` +<Select + addLabelText="Add \"{label}\"?" + arrowRenderer={[Function]} + autosize={true} + backspaceRemoves={true} + backspaceToRemoveMessage="Press backspace to remove {label}" + className="Select-big" + clearAllText="Clear all" + clearValueText="Clear value" + clearable={false} + delimiter="," + disabled={false} + escapeClearsValue={true} + filterOptions={[Function]} + ignoreAccents={true} + ignoreCase={true} + inputProps={Object {}} + isLoading={false} + joinValues={false} + labelKey="name" + matchPos="any" + matchProp="any" + menuBuffer={0} + menuRenderer={[Function]} + multi={false} + noResultsText="No results found" + onBlurResetsInput={true} + onChange={[Function]} + onCloseResetsInput={true} + onInputChange={[Function]} + openAfterFocus={false} + optionComponent={[Function]} + options={ + Array [ + Object { + "email": "tester@testing.ch", + "login": "test", + "name": "Tester", + }, + Object { + "email": "foo@bar.ch", + "login": "foo", + "name": "Foo Bar", + }, + ] + } + pageSize={5} + placeholder="" + required={false} + scrollMenuIntoView={true} + searchable={true} + simpleValue={false} + tabSelectsValue={true} + value={ + Object { + "avatar": "7daf6c79d4802916d83f6266e24850af", + "login": "admin", + "name": "Administrator", + } + } + valueComponent={[Function]} + valueKey="login" /> +`; diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap new file mode 100644 index 00000000000..92c64c9030f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap @@ -0,0 +1,45 @@ +exports[`test should render correctly with email instead of hash 1`] = ` +<div + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseMove={[Function]} + title="Administrator"> + <div + className="little-spacer-bottom little-spacer-top"> + <Connect(Avatar) + email="admin@admin.ch" + size={20} /> + <strong + className="spacer-left"> + admin + </strong> + <span + className="note little-spacer-left"> + Administrator + </span> + </div> +</div> +`; + +exports[`test should render correctly without all parameters 1`] = ` +<div + onMouseDown={[Function]} + onMouseEnter={[Function]} + onMouseMove={[Function]} + title="Administrator"> + <div + className="little-spacer-bottom little-spacer-top"> + <Connect(Avatar) + hash="7daf6c79d4802916d83f6266e24850af" + size={20} /> + <strong + className="spacer-left"> + admin + </strong> + <span + className="note little-spacer-left"> + Administrator + </span> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap new file mode 100644 index 00000000000..2e5a2ab9cd6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap @@ -0,0 +1,47 @@ +exports[`test should render correctly with a user 1`] = ` +<div + className="Select-value" + title="Administrator"> + <div + className="Select-value-label"> + <Connect(Avatar) + hash="7daf6c79d4802916d83f6266e24850af" + size={20} /> + <strong + className="spacer-left"> + admin + </strong> + <span + className="note little-spacer-left"> + Administrator + </span> + </div> +</div> +`; + +exports[`test should render correctly with email instead of hash 1`] = ` +<div + className="Select-value" + title="Administrator"> + <div + className="Select-value-label"> + <Connect(Avatar) + email="admin@admin.ch" + size={20} /> + <strong + className="spacer-left"> + admin + </strong> + <span + className="note little-spacer-left"> + Administrator + </span> + </div> +</div> +`; + +exports[`test should render correctly without value 1`] = ` +<div + className="Select-value" + title="" /> +`; diff --git a/server/sonar-web/src/main/js/store/organizationsMembers/actions.js b/server/sonar-web/src/main/js/store/organizationsMembers/actions.js index 44de107c3f8..08425dafc76 100644 --- a/server/sonar-web/src/main/js/store/organizationsMembers/actions.js +++ b/server/sonar-web/src/main/js/store/organizationsMembers/actions.js @@ -21,8 +21,9 @@ export type Member = { login: string, name: string, - avatar: string, - groupCount: number + avatar?: string, + email?: string, + groupCount?: number }; type MembersState = { @@ -35,7 +36,8 @@ type MembersState = { export const actions = { UPDATE_STATE: 'organizations/UPDATE_STATE', RECEIVE_MEMBERS: 'organizations/RECEIVE_MEMBERS', - RECEIVE_MORE_MEMBERS: 'organizations/RECEIVE_MORE_MEMBERS' + RECEIVE_MORE_MEMBERS: 'organizations/RECEIVE_MORE_MEMBERS', + ADD_MEMBER: 'organizations/ADD_MEMBER', }; export const receiveMembers = (organizationKey: string, members: Array<Member>, stateChanges: MembersState) => ({ @@ -52,6 +54,12 @@ export const receiveMoreMembers = (organizationKey: string, members: Array<Membe stateChanges }); +export const addMember = (organizationKey: string, member: Member) => ({ + type: actions.ADD_MEMBER, + organization: organizationKey, + member +}); + export const updateState = ( organizationKey: string, stateChanges: MembersState diff --git a/server/sonar-web/src/main/js/store/organizationsMembers/reducer.js b/server/sonar-web/src/main/js/store/organizationsMembers/reducer.js index 1e7e5094945..3e635f21dbc 100644 --- a/server/sonar-web/src/main/js/store/organizationsMembers/reducer.js +++ b/server/sonar-web/src/main/js/store/organizationsMembers/reducer.js @@ -31,6 +31,7 @@ export const getOrganizationMembersState = (state, organization) => organization && state[organization] ? state[organization] : {}; const organizationMembers = (state = {}, action = {}) => { + const members = state.members || []; switch (action.type) { case actions.UPDATE_STATE: return { ...state, ...action.stateChanges }; @@ -40,8 +41,16 @@ const organizationMembers = (state = {}, action = {}) => { return { ...state, ...action.stateChanges, - members: uniq((state.members || []).concat(action.members.map(member => member.login))) + members: uniq(members.concat(action.members.map(member => member.login))) }; + case actions.ADD_MEMBER: { + const withNew = [...members, action.member.login].sort(); + return { + ...state, + total: withNew.length, + members: withNew + }; + } default: return state; } @@ -53,6 +62,7 @@ const organizationsMembers = (state = {}, action = {}) => { case actions.UPDATE_STATE: case actions.RECEIVE_MEMBERS: case actions.RECEIVE_MORE_MEMBERS: + case actions.ADD_MEMBER: return { ...state, [action.organization]: organizationMembers(organization, action) 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 a6206ffca58..5a943db98f7 100644 --- a/server/sonar-web/src/main/js/store/users/reducer.js +++ b/server/sonar-web/src/main/js/store/users/reducer.js @@ -29,6 +29,8 @@ const usersByLogin = (state = {}, action = {}) => { case membersActions.RECEIVE_MEMBERS: case membersActions.RECEIVE_MORE_MEMBERS: return { ...state, ...keyBy(action.members, 'login') }; + case membersActions.ADD_MEMBER: + return { ...state, [action.member.login]: action.member }; default: return state; } @@ -41,6 +43,9 @@ const userLogins = (state = [], action = {}) => { case membersActions.RECEIVE_MEMBERS: case membersActions.RECEIVE_MORE_MEMBERS: return uniq([...state, action.members.map(member => member.login)]); + case membersActions.ADD_MEMBER: { + return uniq([...state, action.member.login]).sort(); + } default: return state; } diff --git a/server/sonar-web/src/main/less/components/modals.less b/server/sonar-web/src/main/less/components/modals.less index 65a70f094e7..291d61c7fc8 100644 --- a/server/sonar-web/src/main/less/components/modals.less +++ b/server/sonar-web/src/main/less/components/modals.less @@ -116,6 +116,12 @@ ul.modal-head-metadata li { padding: 5px 0 5px 130px; } +.modal-large-field { + clear: both; + display: block; + padding: 20px 40px; +} + .modal-field label { position: relative; left: -140px; @@ -142,31 +148,54 @@ ul.modal-head-metadata li { } } +.modal-large-field label { + display: inline-block; + padding-bottom: 15px; + font-weight: bold; +} + .readonly-field { padding-top: 5px; margin-left: -5px; line-height: 1; } -.modal-field input, -.modal-field select, -.modal-field textarea { - margin-right: 5px; - margin-bottom: 10px; +.modal-field, .modal-large-field { + input, + select, + textarea, + .Select { + margin-right: 5px; + margin-bottom: 10px; + } + + input[type="radio"], + input[type="checkbox"] { + margin-top: 5px; + margin-bottom: 4px; + } } -.modal-field input[type="radio"], -.modal-field input[type="checkbox"] { - margin-top: 5px; - margin-bottom: 4px; +.modal-field { + input[type=text], + input[type=email], + input[type=password], + textarea, + select, + .Select { + width: 250px; + } } -.modal-field input[type=text], -.modal-field input[type=email], -.modal-field input[type=password], -.modal-field textarea, -.modal-field select { - width: 250px; +.modal-large-field { + input[type=text], + input[type=email], + input[type=password], + textarea, + select, + .Select { + width: 100%; + } } .modal-field-description { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 0d4555c94ba..9bc2e6ac2aa 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1771,6 +1771,9 @@ user.password_cant_be_changed_on_external_auth=Password cannot be changed when e # #------------------------------------------------------------------------------ users.create=Create User +users.add=Add user +users.remove=Remove user +users.search_description=Search by username, full name, or email address #------------------------------------------------------------------------------ # @@ -2817,6 +2820,6 @@ organization.updated=Organization details have been updated. organization.url=Url organization.url.description=Url of the homepage of the organization. organization.members.page=Members -organization.members.add=Add member +organization.members.add=Add a member organization.members.x_group(s)={0} group(s) organization.members.member(s)=member(s) |