@@ -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 }) => |
@@ -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); | |||
} |
@@ -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)); | |||
}); | |||
}; |
@@ -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> |
@@ -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} |
@@ -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); |
@@ -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 |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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(); | |||
}); |
@@ -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> | |||
`; |
@@ -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"> |
@@ -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; | |||
} |
@@ -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} | |||
/> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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(); | |||
}); |
@@ -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(); | |||
}); |
@@ -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(); | |||
}); |
@@ -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" /> | |||
`; |
@@ -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> | |||
`; |
@@ -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="" /> | |||
`; |
@@ -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 |
@@ -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) |
@@ -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; | |||
} |
@@ -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 { |
@@ -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) |