@@ -44,6 +44,7 @@ | |||
}, | |||
"devDependencies": { | |||
"@types/classnames": "2.2.3", | |||
"@types/clipboard": "1.5.35", | |||
"@types/d3-array": "1.2.1", | |||
"@types/d3-scale": "1.0.10", | |||
"@types/enzyme": "3.1.1", |
@@ -154,12 +154,6 @@ export function getMyProjects(data: RequestData): Promise<any> { | |||
return getJSON(url, data); | |||
} | |||
export interface Paging { | |||
pageIndex: number; | |||
pageSize: number; | |||
total: number; | |||
} | |||
export interface Component { | |||
organization: string; | |||
id: string; |
@@ -17,37 +17,34 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { getJSON, postJSON, post, RequestData } from '../helpers/request'; | |||
import { getJSON, postJSON, post } from '../helpers/request'; | |||
import throwGlobalError from '../app/utils/throwGlobalError'; | |||
export interface UserToken { | |||
name: string; | |||
createdAt: string; | |||
} | |||
/** | |||
* List tokens for given user login | |||
*/ | |||
export function getTokens(login: string): Promise<any> { | |||
return getJSON('/api/user_tokens/search', { login }).then(r => r.userTokens); | |||
export function getTokens(login: string): Promise<UserToken[]> { | |||
return getJSON('/api/user_tokens/search', { login }).then(r => r.userTokens, throwGlobalError); | |||
} | |||
/** | |||
* Generate a user token | |||
*/ | |||
export function generateToken( | |||
tokenName: string, | |||
userLogin?: string | |||
): Promise<{ name: string; token: string }> { | |||
const data: RequestData = { name: tokenName }; | |||
if (userLogin) { | |||
data.login = userLogin; | |||
} | |||
export function generateToken(data: { | |||
name: string; | |||
login?: string; | |||
}): Promise<{ login: string; name: string; token: string }> { | |||
return postJSON('/api/user_tokens/generate', data).catch(throwGlobalError); | |||
} | |||
/** | |||
* Revoke a user token | |||
*/ | |||
export function revokeToken(tokenName: string, userLogin?: string): Promise<void | Response> { | |||
const data: RequestData = { name: tokenName }; | |||
if (userLogin) { | |||
data.login = userLogin; | |||
} | |||
export function revokeToken(data: { name: string; login?: string }): Promise<void | Response> { | |||
return post('/api/user_tokens/revoke', data).catch(throwGlobalError); | |||
} |
@@ -17,8 +17,9 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { getJSON, post, RequestData } from '../helpers/request'; | |||
import { getJSON, post, postJSON, RequestData } from '../helpers/request'; | |||
import throwGlobalError from '../app/utils/throwGlobalError'; | |||
import { Paging } from '../app/types'; | |||
export interface IdentityProvider { | |||
backgroundColor: string; | |||
@@ -27,19 +28,29 @@ export interface IdentityProvider { | |||
name: string; | |||
} | |||
export interface User { | |||
login: string; | |||
name: string; | |||
active: boolean; | |||
email?: string; | |||
scmAccounts: string[]; | |||
groups?: string[]; | |||
tokensCount?: number; | |||
local: boolean; | |||
externalIdentity?: string; | |||
externalProvider?: string; | |||
avatar?: string; | |||
} | |||
export function getCurrentUser(): Promise<any> { | |||
return getJSON('/api/users/current'); | |||
} | |||
export function changePassword( | |||
login: string, | |||
password: string, | |||
previousPassword?: string | |||
): Promise<void> { | |||
const data: RequestData = { login, password }; | |||
if (previousPassword != null) { | |||
data.previousPassword = previousPassword; | |||
} | |||
export function changePassword(data: { | |||
login: string; | |||
password: string; | |||
previousPassword?: string; | |||
}): Promise<void> { | |||
return post('/api/users/change_password', data); | |||
} | |||
@@ -52,15 +63,40 @@ export function getUserGroups(login: string, organization?: string): Promise<any | |||
} | |||
export function getIdentityProviders(): Promise<{ identityProviders: IdentityProvider[] }> { | |||
return getJSON('/api/users/identity_providers'); | |||
return getJSON('/api/users/identity_providers').catch(throwGlobalError); | |||
} | |||
export function searchUsers(query: string, pageSize?: number): Promise<any> { | |||
const data: RequestData = { q: query }; | |||
if (pageSize != null) { | |||
data.ps = pageSize; | |||
} | |||
return getJSON('/api/users/search', data); | |||
export function searchUsers(data: { | |||
p?: number; | |||
ps?: number; | |||
q?: string; | |||
}): Promise<{ paging: Paging; users: User[] }> { | |||
data.q = data.q || undefined; | |||
return getJSON('/api/users/search', data).catch(throwGlobalError); | |||
} | |||
export function createUser(data: { | |||
email?: string; | |||
local?: boolean; | |||
login: string; | |||
name: string; | |||
password?: string; | |||
scmAccount: string[]; | |||
}): Promise<void | Response> { | |||
return post('/api/users/create', data); | |||
} | |||
export function updateUser(data: { | |||
email?: string; | |||
login: string; | |||
name?: string; | |||
scmAccount: string[]; | |||
}): Promise<User> { | |||
return postJSON('/api/users/update', data); | |||
} | |||
export function deactivateUser(data: { login: string }): Promise<User> { | |||
return postJSON('/api/users/deactivate', data).catch(throwGlobalError); | |||
} | |||
export function skipOnboarding(): Promise<void | Response> { |
@@ -316,6 +316,10 @@ td.big-spacer-top { | |||
cursor: not-allowed; | |||
} | |||
.no-select { | |||
user-select: none; | |||
} | |||
.no-outline, | |||
.no-outline:focus { | |||
outline: none; |
@@ -27,44 +27,44 @@ export default class Password extends Component { | |||
errors: null | |||
}; | |||
handleSuccessfulChange() { | |||
this.refs.oldPassword.value = ''; | |||
this.refs.password.value = ''; | |||
this.refs.passwordConfirmation.value = ''; | |||
handleSuccessfulChange = () => { | |||
this.oldPassword.value = ''; | |||
this.password.value = ''; | |||
this.passwordConfirmation.value = ''; | |||
this.setState({ success: true, errors: null }); | |||
} | |||
}; | |||
handleFailedChange(e) { | |||
handleFailedChange = e => { | |||
e.response.json().then(r => { | |||
this.refs.oldPassword.focus(); | |||
this.oldPassword.focus(); | |||
this.setErrors(r.errors.map(e => e.msg)); | |||
}); | |||
} | |||
}; | |||
setErrors(errors) { | |||
setErrors = errors => { | |||
this.setState({ | |||
success: false, | |||
errors | |||
}); | |||
} | |||
}; | |||
handleChangePassword(e) { | |||
handleChangePassword = e => { | |||
e.preventDefault(); | |||
const { user } = this.props; | |||
const oldPassword = this.refs.oldPassword.value; | |||
const password = this.refs.password.value; | |||
const passwordConfirmation = this.refs.passwordConfirmation.value; | |||
const previousPassword = this.oldPassword.value; | |||
const password = this.password.value; | |||
const passwordConfirmation = this.passwordConfirmation.value; | |||
if (password !== passwordConfirmation) { | |||
this.refs.password.focus(); | |||
this.password.focus(); | |||
this.setErrors([translate('user.password_doesnt_match_confirmation')]); | |||
} else { | |||
changePassword(user.login, password, oldPassword) | |||
.then(this.handleSuccessfulChange.bind(this)) | |||
.catch(this.handleFailedChange.bind(this)); | |||
changePassword({ login: user.login, password, previousPassword }) | |||
.then(this.handleSuccessfulChange) | |||
.catch(this.handleFailedChange); | |||
} | |||
} | |||
}; | |||
render() { | |||
const { success, errors } = this.state; | |||
@@ -73,7 +73,7 @@ export default class Password extends Component { | |||
<section> | |||
<h2 className="spacer-bottom">{translate('my_profile.password.title')}</h2> | |||
<form onSubmit={this.handleChangePassword.bind(this)}> | |||
<form onSubmit={this.handleChangePassword}> | |||
{success && ( | |||
<div className="alert alert-success">{translate('my_profile.password.changed')}</div> | |||
)} | |||
@@ -91,7 +91,7 @@ export default class Password extends Component { | |||
<em className="mandatory">*</em> | |||
</label> | |||
<input | |||
ref="oldPassword" | |||
ref={elem => (this.oldPassword = elem)} | |||
autoComplete="off" | |||
id="old_password" | |||
name="old_password" | |||
@@ -105,7 +105,7 @@ export default class Password extends Component { | |||
<em className="mandatory">*</em> | |||
</label> | |||
<input | |||
ref="password" | |||
ref={elem => (this.password = elem)} | |||
autoComplete="off" | |||
id="password" | |||
name="password" | |||
@@ -119,7 +119,7 @@ export default class Password extends Component { | |||
<em className="mandatory">*</em> | |||
</label> | |||
<input | |||
ref="passwordConfirmation" | |||
ref={elem => (this.passwordConfirmation = elem)} | |||
autoComplete="off" | |||
id="password_confirmation" | |||
name="password_confirmation" |
@@ -53,7 +53,7 @@ export default Marionette.ItemView.extend({ | |||
this.errors = []; | |||
this.newToken = null; | |||
const tokenName = this.$('.js-generate-token-form input').val(); | |||
generateToken(tokenName, this.model.id).then( | |||
generateToken({ name: tokenName, login: this.model.id }).then( | |||
response => { | |||
this.newToken = response; | |||
this.requestTokens(); | |||
@@ -68,7 +68,10 @@ export default Marionette.ItemView.extend({ | |||
const token = this.tokens.find(token => token.name === `${tokenName}`); | |||
if (token) { | |||
if (token.deleting) { | |||
revokeToken(tokenName, this.model.id).then(this.requestTokens.bind(this), () => {}); | |||
revokeToken({ name: tokenName, login: this.model.id }).then( | |||
() => this.requestTokens(), | |||
() => {} | |||
); | |||
} else { | |||
token.deleting = true; | |||
this.render(); |
@@ -230,7 +230,7 @@ export const searchAssignees = (query /*: string */, organization /*: ?string */ | |||
value: user.login | |||
})) | |||
) | |||
: searchUsers(query, 50).then(response => | |||
: searchUsers({ q: query }).then(response => | |||
response.users.map(user => ({ | |||
// TODO this WS returns no avatar | |||
avatar: user.avatar, |
@@ -95,7 +95,7 @@ export default class TokenStep extends React.PureComponent { | |||
const { tokenName } = this.state; | |||
if (tokenName) { | |||
this.setState({ loading: true }); | |||
generateToken(tokenName).then( | |||
generateToken({ name: tokenName }).then( | |||
({ token }) => { | |||
if (this.mounted) { | |||
this.setState({ loading: false, token }); | |||
@@ -114,7 +114,7 @@ export default class TokenStep extends React.PureComponent { | |||
const { tokenName } = this.state; | |||
if (tokenName) { | |||
this.setState({ loading: true }); | |||
revokeToken(tokenName).then( | |||
revokeToken({ name: tokenName }).then( | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ loading: false, token: undefined, tokenName: undefined }); |
@@ -0,0 +1,59 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import UserForm from './components/UserForm'; | |||
import DeferredSpinner from '../../components/common/DeferredSpinner'; | |||
import { translate } from '../../helpers/l10n'; | |||
interface Props { | |||
loading: boolean; | |||
onUpdateUsers: () => void; | |||
} | |||
interface State { | |||
openUserForm: boolean; | |||
} | |||
export default class Header extends React.PureComponent<Props, State> { | |||
state: State = { openUserForm: false }; | |||
handleOpenUserForm = () => this.setState({ openUserForm: true }); | |||
handleCloseUserForm = () => this.setState({ openUserForm: false }); | |||
render() { | |||
return ( | |||
<header id="users-header" className="page-header"> | |||
<h1 className="page-title">{translate('users.page')}</h1> | |||
<DeferredSpinner loading={this.props.loading} /> | |||
<div className="page-actions"> | |||
<button id="users-create" onClick={this.handleOpenUserForm}> | |||
{translate('users.create_user')} | |||
</button> | |||
</div> | |||
<p className="page-description">{translate('users.page.description')}</p> | |||
{this.state.openUserForm && ( | |||
<UserForm onClose={this.handleCloseUserForm} onUpdateUsers={this.props.onUpdateUsers} /> | |||
)} | |||
</header> | |||
); | |||
} | |||
} |
@@ -17,35 +17,32 @@ | |||
* 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'; */ | |||
import * as React from 'react'; | |||
import { Query } from './utils'; | |||
import SearchBox from '../../components/controls/SearchBox'; | |||
import { translate } from '../../helpers/l10n'; | |||
/*:: | |||
type Props = { | |||
value: Option, | |||
children?: Element | Text | |||
}; | |||
*/ | |||
const AVATAR_SIZE /*: number */ = 16; | |||
interface Props { | |||
query: Query; | |||
updateQuery: (newQuery: Partial<Query>) => void; | |||
} | |||
export default class UsersSelectSearchValue extends React.PureComponent { | |||
/*:: props: Props; */ | |||
export default class Search extends React.PureComponent<Props> { | |||
handleSearch = (search: string) => { | |||
this.props.updateQuery({ search }); | |||
}; | |||
render() { | |||
const user = this.props.value; | |||
const { query } = this.props; | |||
return ( | |||
<div className="Select-value" title={user ? user.name : ''}> | |||
{user && | |||
user.login && ( | |||
<div className="Select-value-label"> | |||
<Avatar hash={user.avatar} name={user.name} size={AVATAR_SIZE} /> | |||
<strong className="spacer-left">{this.props.children}</strong> | |||
<span className="note little-spacer-left">{user.login}</span> | |||
</div> | |||
)} | |||
<div id="users-search" className="panel panel-vertical bordered-bottom spacer-bottom"> | |||
<SearchBox | |||
minLength={2} | |||
onChange={this.handleSearch} | |||
placeholder={translate('search.search_by_login_or_name')} | |||
value={query.search} | |||
/> | |||
</div> | |||
); | |||
} |
@@ -0,0 +1,142 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import * as PropTypes from 'prop-types'; | |||
import Helmet from 'react-helmet'; | |||
import { Location } from 'history'; | |||
import Header from './Header'; | |||
import ListFooter from '../../components/controls/ListFooter'; | |||
import Search from './Search'; | |||
import UsersList from './UsersList'; | |||
import { getIdentityProviders, IdentityProvider, searchUsers, User } from '../../api/users'; | |||
import { Paging } from '../../app/types'; | |||
import { translate } from '../../helpers/l10n'; | |||
import { parseQuery, Query, serializeQuery } from './utils'; | |||
interface Props { | |||
currentUser: { isLoggedIn: boolean; login?: string }; | |||
location: Location; | |||
organizationsEnabled: boolean; | |||
} | |||
interface State { | |||
identityProviders: IdentityProvider[]; | |||
loading: boolean; | |||
paging?: Paging; | |||
users: User[]; | |||
} | |||
export default class UsersApp extends React.PureComponent<Props, State> { | |||
mounted: boolean; | |||
static contextTypes = { | |||
router: PropTypes.object.isRequired | |||
}; | |||
state: State = { identityProviders: [], loading: true, users: [] }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.fetchIdentityProviders(); | |||
this.fetchUsers(); | |||
} | |||
componentWillReceiveProps(nextProps: Props) { | |||
if (nextProps.location.query.search !== this.props.location.query.search) { | |||
this.fetchUsers(nextProps); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
finishLoading = () => { | |||
if (this.mounted) { | |||
this.setState({ loading: false }); | |||
} | |||
}; | |||
fetchIdentityProviders = () => | |||
getIdentityProviders().then( | |||
({ identityProviders }) => { | |||
if (this.mounted) { | |||
this.setState({ identityProviders }); | |||
} | |||
}, | |||
() => {} | |||
); | |||
fetchUsers = ({ location } = this.props) => { | |||
this.setState({ loading: true }); | |||
searchUsers({ q: parseQuery(location.query).search }).then(({ paging, users }) => { | |||
if (this.mounted) { | |||
this.setState({ loading: false, paging, users }); | |||
} | |||
}, this.finishLoading); | |||
}; | |||
fetchMoreUsers = () => { | |||
const { paging } = this.state; | |||
if (paging) { | |||
this.setState({ loading: true }); | |||
searchUsers({ | |||
p: paging.pageIndex + 1, | |||
q: parseQuery(this.props.location.query).search | |||
}).then(({ paging, users }) => { | |||
if (this.mounted) { | |||
this.setState(state => ({ loading: false, users: [...state.users, ...users], paging })); | |||
} | |||
}, this.finishLoading); | |||
} | |||
}; | |||
updateQuery = (newQuery: Partial<Query>) => { | |||
const query = serializeQuery({ ...parseQuery(this.props.location.query), ...newQuery }); | |||
this.context.router.push({ ...this.props.location, query }); | |||
}; | |||
render() { | |||
const query = parseQuery(this.props.location.query); | |||
const { loading, paging, users } = this.state; | |||
return ( | |||
<div id="users-page" className="page page-limited"> | |||
<Helmet title={translate('users.page')} /> | |||
<Header loading={loading} onUpdateUsers={this.fetchUsers} /> | |||
<Search query={query} updateQuery={this.updateQuery} /> | |||
<UsersList | |||
currentUser={this.props.currentUser} | |||
identityProviders={this.state.identityProviders} | |||
onUpdateUsers={this.fetchUsers} | |||
organizationsEnabled={this.props.organizationsEnabled} | |||
users={users} | |||
/> | |||
{paging !== undefined && ( | |||
<ListFooter | |||
count={users.length} | |||
total={paging.total} | |||
ready={!loading} | |||
loadMore={this.fetchMoreUsers} | |||
/> | |||
)} | |||
</div> | |||
); | |||
} | |||
} |
@@ -17,32 +17,23 @@ | |||
* 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'; | |||
import { connect } from 'react-redux'; | |||
import { Location } from 'history'; | |||
import UsersApp from './UsersApp'; | |||
import { areThereCustomOrganizations, getCurrentUser } from '../../store/rootReducer'; | |||
const user = { | |||
login: 'admin', | |||
name: 'Administrator', | |||
avatar: '7daf6c79d4802916d83f6266e24850af' | |||
}; | |||
interface OwnProps { | |||
location: Location; | |||
} | |||
const user2 = { | |||
login: 'admin', | |||
name: 'Administrator', | |||
email: 'admin@admin.ch' | |||
}; | |||
interface StateToProps { | |||
currentUser: { isLoggedIn: boolean; login?: string }; | |||
organizationsEnabled: boolean; | |||
} | |||
it('should render correctly without all parameters', () => { | |||
const wrapper = shallow( | |||
<UsersSelectSearchOption option={user}>{user.name}</UsersSelectSearchOption> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
const mapStateToProps = (state: any) => ({ | |||
currentUser: getCurrentUser(state), | |||
organizationsEnabled: areThereCustomOrganizations(state) | |||
}); | |||
it('should render correctly with email instead of hash', () => { | |||
const wrapper = shallow( | |||
<UsersSelectSearchOption option={user2}>{user.name}</UsersSelectSearchOption> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
export default connect<StateToProps, {}, OwnProps>(mapStateToProps)(UsersApp); |
@@ -0,0 +1,68 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import UserListItem from './components/UserListItem'; | |||
import { IdentityProvider, User } from '../../api/users'; | |||
import { translate } from '../../helpers/l10n'; | |||
interface Props { | |||
currentUser: { isLoggedIn: boolean; login?: string }; | |||
identityProviders: IdentityProvider[]; | |||
onUpdateUsers: () => void; | |||
organizationsEnabled: boolean; | |||
users: User[]; | |||
} | |||
export default function UsersList({ | |||
currentUser, | |||
identityProviders, | |||
onUpdateUsers, | |||
organizationsEnabled, | |||
users | |||
}: Props) { | |||
return ( | |||
<table id="users-list" className="data zebra"> | |||
<thead> | |||
<tr> | |||
<th /> | |||
<th className="nowrap" /> | |||
<th className="nowrap">{translate('my_profile.scm_accounts')}</th> | |||
{!organizationsEnabled && <th className="nowrap">{translate('my_profile.groups')}</th>} | |||
<th className="nowrap">{translate('users.tokens')}</th> | |||
<th className="nowrap"> </th> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
{users.map(user => ( | |||
<UserListItem | |||
identityProvider={identityProviders.find( | |||
provider => user.externalProvider === provider.key | |||
)} | |||
isCurrentUser={currentUser.isLoggedIn && currentUser.login === user.login} | |||
key={user.login} | |||
onUpdateUsers={onUpdateUsers} | |||
organizationsEnabled={organizationsEnabled} | |||
user={user} | |||
/> | |||
))} | |||
</tbody> | |||
</table> | |||
); | |||
} |
@@ -0,0 +1,95 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import Modal from '../../../components/controls/Modal'; | |||
import { deactivateUser, User } from '../../../api/users'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
export interface Props { | |||
onClose: () => void; | |||
onUpdateUsers: () => void; | |||
user: User; | |||
} | |||
interface State { | |||
submitting: boolean; | |||
} | |||
export default class DeactivateForm extends React.PureComponent<Props, State> { | |||
mounted: boolean; | |||
state: State = { submitting: false }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => { | |||
event.preventDefault(); | |||
this.props.onClose(); | |||
}; | |||
handleDeactivate = (event: React.SyntheticEvent<HTMLFormElement>) => { | |||
event.preventDefault(); | |||
this.setState({ submitting: true }); | |||
deactivateUser({ login: this.props.user.login }).then( | |||
() => { | |||
this.props.onUpdateUsers(); | |||
this.props.onClose(); | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ submitting: false }); | |||
} | |||
} | |||
); | |||
}; | |||
render() { | |||
const { user } = this.props; | |||
const { submitting } = this.state; | |||
const header = translate('users.deactivate_user'); | |||
return ( | |||
<Modal contentLabel={header} onRequestClose={this.props.onClose}> | |||
<form id="deactivate-user-form" onSubmit={this.handleDeactivate} autoComplete="off"> | |||
<header className="modal-head"> | |||
<h2>{header}</h2> | |||
</header> | |||
<div className="modal-body"> | |||
{translateWithParameters('users.deactivate_user.confirmation', user.name, user.login)} | |||
</div> | |||
<footer className="modal-foot"> | |||
{submitting && <i className="spinner spacer-right" />} | |||
<button className="js-confirm button-red" disabled={submitting} type="submit"> | |||
{translate('users.deactivate')} | |||
</button> | |||
<a className="js-modal-close" href="#" onClick={this.handleCancelClick}> | |||
{translate('cancel')} | |||
</a> | |||
</footer> | |||
</form> | |||
</Modal> | |||
); | |||
} | |||
} |
@@ -0,0 +1,97 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import * as escapeHtml from 'escape-html'; | |||
import Modal from '../../../components/controls/Modal'; | |||
import SelectList from '../../../components/SelectList'; | |||
import { User } from '../../../api/users'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { getBaseUrl } from '../../../helpers/urls'; | |||
interface Props { | |||
onClose: () => void; | |||
onUpdateUsers: () => void; | |||
user: User; | |||
} | |||
export default class GroupsForm extends React.PureComponent<Props> { | |||
container: HTMLDivElement | null; | |||
handleCloseClick = (event: React.SyntheticEvent<HTMLElement>) => { | |||
event.preventDefault(); | |||
this.handleClose(); | |||
}; | |||
handleClose = () => { | |||
this.props.onUpdateUsers(); | |||
this.props.onClose(); | |||
}; | |||
renderSelectList = () => { | |||
const searchUrl = `${getBaseUrl()}/api/users/groups?ps=100&login=${encodeURIComponent( | |||
this.props.user.login | |||
)}`; | |||
new (SelectList as any)({ | |||
el: this.container, | |||
width: '100%', | |||
readOnly: false, | |||
focusSearch: false, | |||
dangerouslyUnescapedHtmlFormat: (item: { name: string; description: string }) => | |||
`${escapeHtml(item.name)}<br><span class="note">${escapeHtml(item.description)}</span>`, | |||
queryParam: 'q', | |||
searchUrl, | |||
selectUrl: getBaseUrl() + '/api/user_groups/add_user', | |||
deselectUrl: getBaseUrl() + '/api/user_groups/remove_user', | |||
extra: { login: this.props.user.login }, | |||
selectParameter: 'id', | |||
selectParameterValue: 'id', | |||
parse(r: any) { | |||
this.more = false; | |||
return r.groups; | |||
} | |||
}); | |||
}; | |||
render() { | |||
const header = translate('users.update_groups'); | |||
return ( | |||
<Modal | |||
contentLabel={header} | |||
onAfterOpen={this.renderSelectList} | |||
onRequestClose={this.handleClose}> | |||
<div className="modal-head"> | |||
<h2>{header}</h2> | |||
</div> | |||
<div className="modal-body"> | |||
<div id="user-groups" ref={node => (this.container = node)} /> | |||
</div> | |||
<footer className="modal-foot"> | |||
<a className="js-modal-close" href="#" onClick={this.handleCloseClick}> | |||
{translate('Done')} | |||
</a> | |||
</footer> | |||
</Modal> | |||
); | |||
} | |||
} |
@@ -0,0 +1,182 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import Modal from '../../../components/controls/Modal'; | |||
import addGlobalSuccessMessage from '../../../app/utils/addGlobalSuccessMessage'; | |||
import throwGlobalError from '../../../app/utils/throwGlobalError'; | |||
import { parseError } from '../../../helpers/request'; | |||
import { changePassword, User } from '../../../api/users'; | |||
import { translate } from '../../../helpers/l10n'; | |||
interface Props { | |||
isCurrentUser: boolean; | |||
user: User; | |||
onClose: () => void; | |||
} | |||
interface State { | |||
confirmPassword: string; | |||
error?: string; | |||
newPassword: string; | |||
oldPassword: string; | |||
submitting: boolean; | |||
} | |||
export default class PasswordForm extends React.PureComponent<Props, State> { | |||
mounted: boolean; | |||
state: State = { | |||
confirmPassword: '', | |||
newPassword: '', | |||
oldPassword: '', | |||
submitting: false | |||
}; | |||
componentDidMount() { | |||
this.mounted = true; | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
handleError = (error: { response: Response }) => { | |||
if (!this.mounted || error.response.status !== 400) { | |||
return throwGlobalError(error); | |||
} else { | |||
return parseError(error).then( | |||
errorMsg => this.setState({ error: errorMsg, submitting: false }), | |||
throwGlobalError | |||
); | |||
} | |||
}; | |||
handleConfirmPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) => | |||
this.setState({ confirmPassword: event.currentTarget.value }); | |||
handleNewPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) => | |||
this.setState({ newPassword: event.currentTarget.value }); | |||
handleOldPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) => | |||
this.setState({ oldPassword: event.currentTarget.value }); | |||
handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => { | |||
event.preventDefault(); | |||
this.props.onClose(); | |||
}; | |||
handleChangePassword = (event: React.SyntheticEvent<HTMLFormElement>) => { | |||
event.preventDefault(); | |||
if ( | |||
this.state.newPassword.length > 0 && | |||
this.state.newPassword === this.state.confirmPassword | |||
) { | |||
this.setState({ submitting: true }); | |||
changePassword({ | |||
login: this.props.user.login, | |||
password: this.state.newPassword, | |||
previousPassword: this.state.oldPassword | |||
}).then(() => { | |||
addGlobalSuccessMessage(translate('my_profile.password.changed')); | |||
this.props.onClose(); | |||
}, this.handleError); | |||
} | |||
}; | |||
render() { | |||
const { error, submitting, newPassword, confirmPassword } = this.state; | |||
const header = translate('my_profile.password.title'); | |||
return ( | |||
<Modal contentLabel={header} onRequestClose={this.props.onClose}> | |||
<form id="user-password-form" onSubmit={this.handleChangePassword} autoComplete="off"> | |||
<header className="modal-head"> | |||
<h2>{header}</h2> | |||
</header> | |||
<div className="modal-body"> | |||
{error && <p className="alert alert-danger">{error}</p>} | |||
{this.props.isCurrentUser && ( | |||
<div className="modal-field"> | |||
<label htmlFor="old-user-password"> | |||
{translate('my_profile.password.old')} | |||
<em className="mandatory">*</em> | |||
</label> | |||
{/* keep this fake field to hack browser autofill */} | |||
<input name="old-password-fake" type="password" className="hidden" /> | |||
<input | |||
id="old-user-password" | |||
name="old-password" | |||
type="password" | |||
maxLength={50} | |||
onChange={this.handleOldPasswordChange} | |||
required={true} | |||
value={this.state.oldPassword} | |||
/> | |||
</div> | |||
)} | |||
<div className="modal-field"> | |||
<label htmlFor="user-password"> | |||
{translate('my_profile.password.new')} | |||
<em className="mandatory">*</em> | |||
</label> | |||
{/* keep this fake field to hack browser autofill */} | |||
<input name="password-fake" type="password" className="hidden" /> | |||
<input | |||
id="user-password" | |||
name="password" | |||
type="password" | |||
maxLength={50} | |||
onChange={this.handleNewPasswordChange} | |||
required={true} | |||
value={this.state.newPassword} | |||
/> | |||
</div> | |||
<div className="modal-field"> | |||
<label htmlFor="confirm-user-password"> | |||
{translate('my_profile.password.confirm')} | |||
<em className="mandatory">*</em> | |||
</label> | |||
{/* keep this fake field to hack browser autofill */} | |||
<input name="confirm-password-fake" type="password" className="hidden" /> | |||
<input | |||
id="confirm-user-password" | |||
name="confirm-password" | |||
type="password" | |||
maxLength={50} | |||
onChange={this.handleConfirmPasswordChange} | |||
required={true} | |||
value={this.state.confirmPassword} | |||
/> | |||
</div> | |||
</div> | |||
<footer className="modal-foot"> | |||
{submitting && <i className="spinner spacer-right" />} | |||
<button | |||
className="js-confirm" | |||
disabled={submitting || !newPassword || newPassword !== confirmPassword} | |||
type="submit"> | |||
{translate('change_verb')} | |||
</button> | |||
<a className="js-modal-close" href="#" onClick={this.handleCancelClick}> | |||
{translate('cancel')} | |||
</a> | |||
</footer> | |||
</form> | |||
</Modal> | |||
); | |||
} | |||
} |
@@ -0,0 +1,202 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import Modal from '../../../components/controls/Modal'; | |||
import TokensFormItem from './TokensFormItem'; | |||
import TokensFormNewToken from './TokensFormNewToken'; | |||
import DeferredSpinner from '../../../components/common/DeferredSpinner'; | |||
import { User } from '../../../api/users'; | |||
import { getTokens, generateToken, UserToken } from '../../../api/user-tokens'; | |||
import { translate } from '../../../helpers/l10n'; | |||
interface Props { | |||
user: User; | |||
onClose: () => void; | |||
onUpdateUsers: () => void; | |||
} | |||
interface State { | |||
generating: boolean; | |||
hasChanged: boolean; | |||
loading: boolean; | |||
newToken?: { name: string; token: string }; | |||
newTokenName: string; | |||
tokens: UserToken[]; | |||
} | |||
export default class TokensForm extends React.PureComponent<Props, State> { | |||
mounted: boolean; | |||
state: State = { | |||
generating: false, | |||
hasChanged: false, | |||
loading: true, | |||
newTokenName: '', | |||
tokens: [] | |||
}; | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.fetchTokens(); | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
fetchTokens = ({ user } = this.props) => { | |||
this.setState({ loading: true }); | |||
getTokens(user.login).then( | |||
tokens => { | |||
if (this.mounted) { | |||
this.setState({ loading: false, tokens }); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ loading: false }); | |||
} | |||
} | |||
); | |||
}; | |||
handleCloseClick = (evt: React.SyntheticEvent<HTMLAnchorElement>) => { | |||
evt.preventDefault(); | |||
this.handleClose(); | |||
}; | |||
handleClose = () => { | |||
if (this.state.hasChanged) { | |||
this.props.onUpdateUsers(); | |||
} | |||
this.props.onClose(); | |||
}; | |||
handleGenerateToken = (evt: React.SyntheticEvent<HTMLFormElement>) => { | |||
evt.preventDefault(); | |||
if (this.state.newTokenName.length > 0) { | |||
this.setState({ generating: true }); | |||
generateToken({ name: this.state.newTokenName, login: this.props.user.login }).then( | |||
newToken => { | |||
if (this.mounted) { | |||
this.fetchTokens(); | |||
this.setState({ generating: false, hasChanged: true, newToken, newTokenName: '' }); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ generating: false }); | |||
} | |||
} | |||
); | |||
} | |||
}; | |||
handleRevokeToken = () => { | |||
this.setState({ hasChanged: true }); | |||
this.fetchTokens(); | |||
}; | |||
handleNewTokenChange = (evt: React.SyntheticEvent<HTMLInputElement>) => | |||
this.setState({ newTokenName: evt.currentTarget.value }); | |||
renderItems() { | |||
const { tokens } = this.state; | |||
if (tokens.length <= 0) { | |||
return ( | |||
<tr> | |||
<td colSpan={3} className="note"> | |||
{translate('users.no_tokens')} | |||
</td> | |||
</tr> | |||
); | |||
} | |||
return tokens.map(token => ( | |||
<TokensFormItem | |||
key={token.name} | |||
token={token} | |||
onRevokeToken={this.handleRevokeToken} | |||
user={this.props.user} | |||
/> | |||
)); | |||
} | |||
render() { | |||
const { generating, loading, newToken, newTokenName, tokens } = this.state; | |||
const header = translate('users.tokens'); | |||
const customSpinner = ( | |||
<tr> | |||
<td> | |||
<i className="spinner" /> | |||
</td> | |||
</tr> | |||
); | |||
return ( | |||
<Modal contentLabel={header} onRequestClose={this.handleClose}> | |||
<header className="modal-head"> | |||
<h2>{header}</h2> | |||
</header> | |||
<div className="modal-body modal-container"> | |||
<h3 className="spacer-bottom">{translate('users.generate_tokens')}</h3> | |||
<form id="generate-token-form" onSubmit={this.handleGenerateToken} autoComplete="off"> | |||
<input | |||
className="spacer-right" | |||
type="text" | |||
maxLength={100} | |||
onChange={this.handleNewTokenChange} | |||
placeholder={translate('users.enter_token_name')} | |||
required={true} | |||
value={newTokenName} | |||
/> | |||
<button | |||
className="js-generate-token" | |||
disabled={generating || newTokenName.length <= 0} | |||
type="submit"> | |||
{translate('users.generate')} | |||
</button> | |||
</form> | |||
{newToken && <TokensFormNewToken token={newToken} />} | |||
<table className="data zebra big-spacer-top "> | |||
<thead> | |||
<tr> | |||
<th>{translate('name')}</th> | |||
<th className="text-right">{translate('created')}</th> | |||
<th /> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
<DeferredSpinner | |||
customSpinner={customSpinner} | |||
loading={loading && tokens.length <= 0}> | |||
{this.renderItems()} | |||
</DeferredSpinner> | |||
</tbody> | |||
</table> | |||
</div> | |||
<footer className="modal-foot"> | |||
<a className="js-modal-close" href="#" onClick={this.handleCloseClick}> | |||
{translate('Done')} | |||
</a> | |||
</footer> | |||
</Modal> | |||
); | |||
} | |||
} |
@@ -0,0 +1,97 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import Tooltip from '../../../components/controls/Tooltip'; | |||
import DateFormatter from '../../../components/intl/DateFormatter'; | |||
import DeferredSpinner from '../../../components/common/DeferredSpinner'; | |||
import { User } from '../../../api/users'; | |||
import { revokeToken, UserToken } from '../../../api/user-tokens'; | |||
import { limitComponentName } from '../../../helpers/path'; | |||
import { translate } from '../../../helpers/l10n'; | |||
interface Props { | |||
token: UserToken; | |||
user: User; | |||
onRevokeToken: () => void; | |||
} | |||
interface State { | |||
deleting: boolean; | |||
loading: boolean; | |||
} | |||
export default class TokensFormItem extends React.PureComponent<Props, State> { | |||
mounted: boolean; | |||
state: State = { deleting: false, loading: false }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
handleRevoke = () => { | |||
if (this.state.deleting) { | |||
this.setState({ loading: true }); | |||
revokeToken({ login: this.props.user.login, name: this.props.token.name }).then( | |||
this.props.onRevokeToken, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ loading: false, deleting: false }); | |||
} | |||
} | |||
); | |||
} else { | |||
this.setState({ deleting: true }); | |||
} | |||
}; | |||
render() { | |||
const { token } = this.props; | |||
const { loading } = this.state; | |||
return ( | |||
<tr> | |||
<td> | |||
<Tooltip overlay={token.name}> | |||
<span>{limitComponentName(token.name)}</span> | |||
</Tooltip> | |||
</td> | |||
<td className="thin nowrap text-right"> | |||
<DateFormatter date={token.createdAt} long={true} /> | |||
</td> | |||
<td className="thin nowrap text-right"> | |||
<DeferredSpinner loading={loading}> | |||
<i className="spinner-placeholder " /> | |||
</DeferredSpinner> | |||
<button | |||
className="button-red input-small spacer-left" | |||
onClick={this.handleRevoke} | |||
disabled={loading}> | |||
{this.state.deleting | |||
? translate('users.tokens.sure') | |||
: translate('users.tokens.revoke')} | |||
</button> | |||
</td> | |||
</tr> | |||
); | |||
} | |||
} |
@@ -0,0 +1,101 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import * as Clipboard from 'clipboard'; | |||
import Tooltip from '../../../components/controls/Tooltip'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
interface Props { | |||
token: { name: string; token: string }; | |||
} | |||
interface State { | |||
tooltipShown: boolean; | |||
} | |||
export default class TokensFormNewToken extends React.PureComponent<Props, State> { | |||
clipboard: Clipboard; | |||
copyButton: HTMLButtonElement | null; | |||
mounted: boolean; | |||
state: State = { tooltipShown: false }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
if (this.copyButton) { | |||
this.clipboard = new Clipboard(this.copyButton); | |||
this.clipboard.on('success', this.showTooltip); | |||
} | |||
} | |||
componentDidUpdate() { | |||
this.clipboard.destroy(); | |||
if (this.copyButton) { | |||
this.clipboard = new Clipboard(this.copyButton); | |||
this.clipboard.on('success', this.showTooltip); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
this.clipboard.destroy(); | |||
} | |||
showTooltip = () => { | |||
if (this.mounted) { | |||
this.setState({ tooltipShown: true }); | |||
setTimeout(() => { | |||
if (this.mounted) { | |||
this.setState({ tooltipShown: false }); | |||
} | |||
}, 1000); | |||
} | |||
}; | |||
render() { | |||
const { name, token } = this.props.token; | |||
const button = ( | |||
<button | |||
className="js-copy-to-clipboard no-select" | |||
data-clipboard-text={token} | |||
ref={node => (this.copyButton = node)}> | |||
{translate('copy')} | |||
</button> | |||
); | |||
return ( | |||
<div className="panel panel-white big-spacer-top"> | |||
<p className="alert alert-warning"> | |||
{translateWithParameters('users.tokens.new_token_created', name)} | |||
</p> | |||
{this.state.tooltipShown ? ( | |||
<Tooltip | |||
defaultVisible={true} | |||
placement="bottom" | |||
overlay={translate('users.tokens.copied')} | |||
trigger="manual"> | |||
{button} | |||
</Tooltip> | |||
) : ( | |||
button | |||
)} | |||
<code className="big-spacer-left text-success">{token}</code> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,113 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import ActionsDropdown, { | |||
ActionsDropdownItem, | |||
ActionsDropdownDivider | |||
} from '../../../components/controls/ActionsDropdown'; | |||
import DeactivateForm from './DeactivateForm'; | |||
import PasswordForm from './PasswordForm'; | |||
import UserForm from './UserForm'; | |||
import { User } from '../../../api/users'; | |||
import { translate } from '../../../helpers/l10n'; | |||
interface Props { | |||
isCurrentUser: boolean; | |||
onUpdateUsers: () => void; | |||
user: User; | |||
} | |||
interface State { | |||
openForm?: string; | |||
} | |||
export default class UserActions extends React.PureComponent<Props, State> { | |||
state: State = {}; | |||
handleOpenDeactivateForm = () => this.setState({ openForm: 'deactivate' }); | |||
handleOpenPasswordForm = () => this.setState({ openForm: 'password' }); | |||
handleOpenUpdateForm = () => this.setState({ openForm: 'update' }); | |||
handleCloseForm = () => this.setState({ openForm: undefined }); | |||
renderActions = () => { | |||
const { user } = this.props; | |||
return ( | |||
<ActionsDropdown key="actions" menuClassName="dropdown-menu-right"> | |||
<ActionsDropdownItem className="js-user-update" onClick={this.handleOpenUpdateForm}> | |||
{translate('update_details')} | |||
</ActionsDropdownItem> | |||
{user.local && ( | |||
<ActionsDropdownItem | |||
className="js-user-change-password" | |||
onClick={this.handleOpenPasswordForm}> | |||
{translate('my_profile.password.title')} | |||
</ActionsDropdownItem> | |||
)} | |||
<ActionsDropdownDivider /> | |||
<ActionsDropdownItem | |||
className="js-user-deactivate" | |||
destructive={true} | |||
onClick={this.handleOpenDeactivateForm}> | |||
{translate('users.deactivate')} | |||
</ActionsDropdownItem> | |||
</ActionsDropdown> | |||
); | |||
}; | |||
render() { | |||
const { openForm } = this.state; | |||
const { isCurrentUser, onUpdateUsers, user } = this.props; | |||
if (openForm === 'deactivate') { | |||
return [ | |||
this.renderActions(), | |||
<DeactivateForm | |||
key="form" | |||
onClose={this.handleCloseForm} | |||
onUpdateUsers={onUpdateUsers} | |||
user={user} | |||
/> | |||
]; | |||
} | |||
if (openForm === 'password') { | |||
return [ | |||
this.renderActions(), | |||
<PasswordForm | |||
isCurrentUser={isCurrentUser} | |||
key="form" | |||
onClose={this.handleCloseForm} | |||
user={user} | |||
/> | |||
]; | |||
} | |||
if (openForm === 'update') { | |||
return [ | |||
this.renderActions(), | |||
<UserForm | |||
key="form" | |||
onClose={this.handleCloseForm} | |||
onUpdateUsers={onUpdateUsers} | |||
user={user} | |||
/> | |||
]; | |||
} | |||
return this.renderActions(); | |||
} | |||
} |
@@ -0,0 +1,270 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import { uniq } from 'lodash'; | |||
import Modal from '../../../components/controls/Modal'; | |||
import UserScmAccountInput from './UserScmAccountInput'; | |||
import throwGlobalError from '../../../app/utils/throwGlobalError'; | |||
import { parseError } from '../../../helpers/request'; | |||
import { createUser, updateUser, User } from '../../../api/users'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
export interface Props { | |||
user?: User; | |||
onClose: () => void; | |||
onUpdateUsers: () => void; | |||
} | |||
interface State { | |||
email: string; | |||
error?: string; | |||
login: string; | |||
name: string; | |||
password: string; | |||
scmAccounts: string[]; | |||
submitting: boolean; | |||
} | |||
export default class UserForm extends React.PureComponent<Props, State> { | |||
mounted: boolean; | |||
constructor(props: Props) { | |||
super(props); | |||
const { user } = props; | |||
if (user) { | |||
this.state = { | |||
email: user.email || '', | |||
login: user.login, | |||
name: user.name, | |||
password: '', | |||
scmAccounts: user.scmAccounts, | |||
submitting: false | |||
}; | |||
} else { | |||
this.state = { | |||
email: '', | |||
login: '', | |||
name: '', | |||
password: '', | |||
scmAccounts: [], | |||
submitting: false | |||
}; | |||
} | |||
} | |||
componentDidMount() { | |||
this.mounted = true; | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
handleError = (error: { response: Response }) => { | |||
if (!this.mounted || ![400, 500].includes(error.response.status)) { | |||
return throwGlobalError(error); | |||
} else { | |||
return parseError(error).then( | |||
errorMsg => this.setState({ error: errorMsg, submitting: false }), | |||
throwGlobalError | |||
); | |||
} | |||
}; | |||
handleEmailChange = (event: React.SyntheticEvent<HTMLInputElement>) => | |||
this.setState({ email: event.currentTarget.value }); | |||
handleLoginChange = (event: React.SyntheticEvent<HTMLInputElement>) => | |||
this.setState({ login: event.currentTarget.value }); | |||
handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => | |||
this.setState({ name: event.currentTarget.value }); | |||
handlePasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) => | |||
this.setState({ password: event.currentTarget.value }); | |||
handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => { | |||
event.preventDefault(); | |||
this.props.onClose(); | |||
}; | |||
handleCreateUser = (event: React.SyntheticEvent<HTMLFormElement>) => { | |||
event.preventDefault(); | |||
this.setState({ submitting: true }); | |||
createUser({ | |||
email: this.state.email || undefined, | |||
login: this.state.login, | |||
name: this.state.name, | |||
password: this.state.password, | |||
scmAccount: uniq(this.state.scmAccounts) | |||
}).then(() => { | |||
this.props.onUpdateUsers(); | |||
this.props.onClose(); | |||
}, this.handleError); | |||
}; | |||
handleUpdateUser = (event: React.SyntheticEvent<HTMLFormElement>) => { | |||
event.preventDefault(); | |||
this.setState({ submitting: true }); | |||
updateUser({ | |||
email: this.state.email || undefined, | |||
login: this.state.login, | |||
name: this.state.name, | |||
scmAccount: uniq(this.state.scmAccounts) | |||
}).then(() => { | |||
this.props.onUpdateUsers(); | |||
this.props.onClose(); | |||
}, this.handleError); | |||
}; | |||
handleAddScmAccount = (evt: React.SyntheticEvent<HTMLButtonElement>) => { | |||
evt.preventDefault(); | |||
this.setState(({ scmAccounts }) => ({ scmAccounts: scmAccounts.concat('') })); | |||
}; | |||
handleUpdateScmAccount = (idx: number, scmAccount: string) => | |||
this.setState(({ scmAccounts: oldScmAccounts }) => { | |||
const scmAccounts = oldScmAccounts.slice(); | |||
scmAccounts[idx] = scmAccount; | |||
return { scmAccounts }; | |||
}); | |||
handleRemoveScmAccount = (idx: number) => | |||
this.setState(({ scmAccounts }) => ({ | |||
scmAccounts: scmAccounts.slice(0, idx).concat(scmAccounts.slice(idx + 1)) | |||
})); | |||
render() { | |||
const { user } = this.props; | |||
const { error, submitting } = this.state; | |||
const header = user ? translate('users.update_user') : translate('users.create_user'); | |||
return ( | |||
<Modal contentLabel={header} onRequestClose={this.props.onClose}> | |||
<form | |||
id="user-form" | |||
onSubmit={this.props.user ? this.handleUpdateUser : this.handleCreateUser} | |||
autoComplete="off"> | |||
<header className="modal-head"> | |||
<h2>{header}</h2> | |||
</header> | |||
<div className="modal-body"> | |||
{error && <p className="alert alert-danger">{error}</p>} | |||
{!user && ( | |||
<div className="modal-field"> | |||
<label htmlFor="create-user-login"> | |||
{translate('login')} | |||
<em className="mandatory">*</em> | |||
</label> | |||
{/* keep this fake field to hack browser autofill */} | |||
<input name="login-fake" type="text" className="hidden" /> | |||
<input | |||
id="create-user-login" | |||
name="login" | |||
type="text" | |||
minLength={3} | |||
maxLength={255} | |||
onChange={this.handleLoginChange} | |||
required={true} | |||
value={this.state.login} | |||
/> | |||
<p className="note">{translateWithParameters('users.minimum_x_characters', 3)}</p> | |||
</div> | |||
)} | |||
<div className="modal-field"> | |||
<label htmlFor="create-user-name"> | |||
{translate('name')} | |||
<em className="mandatory">*</em> | |||
</label> | |||
{/* keep this fake field to hack browser autofill */} | |||
<input name="name-fake" type="text" className="hidden" /> | |||
<input | |||
id="create-user-name" | |||
name="name" | |||
type="text" | |||
maxLength={200} | |||
onChange={this.handleNameChange} | |||
required={true} | |||
value={this.state.name} | |||
/> | |||
</div> | |||
<div className="modal-field"> | |||
<label htmlFor="create-user-email">{translate('users.email')}</label> | |||
{/* keep this fake field to hack browser autofill */} | |||
<input name="email-fake" type="email" className="hidden" /> | |||
<input | |||
id="create-user-email" | |||
name="email" | |||
type="email" | |||
maxLength={100} | |||
onChange={this.handleEmailChange} | |||
value={this.state.email} | |||
/> | |||
</div> | |||
{!user && ( | |||
<div className="modal-field"> | |||
<label htmlFor="create-user-password"> | |||
{translate('password')} | |||
<em className="mandatory">*</em> | |||
</label> | |||
{/* keep this fake field to hack browser autofill */} | |||
<input name="password-fake" type="password" className="hidden" /> | |||
<input | |||
id="create-user-password" | |||
name="password" | |||
type="password" | |||
maxLength={50} | |||
onChange={this.handlePasswordChange} | |||
required={true} | |||
value={this.state.password} | |||
/> | |||
</div> | |||
)} | |||
<div className="modal-field"> | |||
<label>{translate('my_profile.scm_accounts')}</label> | |||
{this.state.scmAccounts.map((scm, idx) => ( | |||
<UserScmAccountInput | |||
idx={idx} | |||
key={idx} | |||
onChange={this.handleUpdateScmAccount} | |||
onRemove={this.handleRemoveScmAccount} | |||
scmAccount={scm} | |||
/> | |||
))} | |||
<div className="spacer-bottom"> | |||
<button onClick={this.handleAddScmAccount}>{translate('add_verb')}</button> | |||
</div> | |||
<p className="note">{translate('user.login_or_email_used_as_scm_account')}</p> | |||
</div> | |||
</div> | |||
<footer className="modal-foot"> | |||
{submitting && <i className="spinner spacer-right" />} | |||
<button className="js-confirm" disabled={submitting} type="submit"> | |||
{user ? translate('update_verb') : translate('create')} | |||
</button> | |||
<a className="js-modal-close" href="#" onClick={this.handleCancelClick}> | |||
{translate('cancel')} | |||
</a> | |||
</footer> | |||
</form> | |||
</Modal> | |||
); | |||
} | |||
} |
@@ -0,0 +1,95 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import BulletListIcon from '../../../components/icons-components/BulletListIcon'; | |||
import GroupsForm from './GroupsForm'; | |||
import { User } from '../../../api/users'; | |||
import { ButtonIcon } from '../../../components/ui/buttons'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
interface Props { | |||
groups: string[]; | |||
onUpdateUsers: () => void; | |||
user: User; | |||
} | |||
interface State { | |||
openForm: boolean; | |||
showMore: boolean; | |||
} | |||
const GROUPS_LIMIT = 3; | |||
export default class UserGroups extends React.PureComponent<Props, State> { | |||
state: State = { openForm: false, showMore: false }; | |||
handleOpenForm = () => this.setState({ openForm: true }); | |||
handleCloseForm = () => this.setState({ openForm: false }); | |||
toggleShowMore = (evt: React.SyntheticEvent<HTMLAnchorElement>) => { | |||
evt.preventDefault(); | |||
this.setState(state => ({ showMore: !state.showMore })); | |||
}; | |||
render() { | |||
const { groups } = this.props; | |||
const limit = groups.length > GROUPS_LIMIT ? GROUPS_LIMIT - 1 : GROUPS_LIMIT; | |||
return ( | |||
<ul> | |||
{groups.slice(0, limit).map(group => ( | |||
<li key={group} className="little-spacer-bottom"> | |||
{group} | |||
</li> | |||
))} | |||
{groups.length > GROUPS_LIMIT && | |||
this.state.showMore && | |||
groups.slice(limit).map(group => ( | |||
<li key={group} className="little-spacer-bottom"> | |||
{group} | |||
</li> | |||
))} | |||
<li className="little-spacer-bottom"> | |||
{groups.length > GROUPS_LIMIT && | |||
!this.state.showMore && ( | |||
<a | |||
className="js-user-more-groups spacer-right" | |||
href="#" | |||
onClick={this.toggleShowMore}> | |||
{translateWithParameters('more_x', groups.length - limit)} | |||
</a> | |||
)} | |||
<ButtonIcon | |||
className="js-user-groups button-small" | |||
onClick={this.handleOpenForm} | |||
tooltip={translate('users.update_groups')}> | |||
<BulletListIcon /> | |||
</ButtonIcon> | |||
</li> | |||
{this.state.openForm && ( | |||
<GroupsForm | |||
onClose={this.handleCloseForm} | |||
onUpdateUsers={this.props.onUpdateUsers} | |||
user={this.props.user} | |||
/> | |||
)} | |||
</ul> | |||
); | |||
} | |||
} |
@@ -0,0 +1,93 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import Avatar from '../../../components/ui/Avatar'; | |||
import BulletListIcon from '../../../components/icons-components/BulletListIcon'; | |||
import { ButtonIcon } from '../../../components/ui/buttons'; | |||
import TokensForm from './TokensForm'; | |||
import UserActions from './UserActions'; | |||
import UserGroups from './UserGroups'; | |||
import UserListItemIdentity from './UserListItemIdentity'; | |||
import UserScmAccounts from './UserScmAccounts'; | |||
import { IdentityProvider, User } from '../../../api/users'; | |||
import { translate } from '../../../helpers/l10n'; | |||
interface Props { | |||
identityProvider?: IdentityProvider; | |||
isCurrentUser: boolean; | |||
onUpdateUsers: () => void; | |||
organizationsEnabled: boolean; | |||
user: User; | |||
} | |||
interface State { | |||
openTokenForm: boolean; | |||
} | |||
export default class UserListItem extends React.PureComponent<Props, State> { | |||
state: State = { openTokenForm: false }; | |||
handleOpenTokensForm = () => this.setState({ openTokenForm: true }); | |||
handleCloseTokensForm = () => this.setState({ openTokenForm: false }); | |||
render() { | |||
const { identityProvider, onUpdateUsers, organizationsEnabled, user } = this.props; | |||
return ( | |||
<tr> | |||
<td className="thin nowrap"> | |||
<Avatar hash={user.avatar} name={user.name} size={36} /> | |||
</td> | |||
<UserListItemIdentity identityProvider={identityProvider} user={user} /> | |||
<td> | |||
<UserScmAccounts scmAccounts={user.scmAccounts || []} /> | |||
</td> | |||
{!organizationsEnabled && ( | |||
<td> | |||
<UserGroups groups={user.groups || []} user={user} onUpdateUsers={onUpdateUsers} /> | |||
</td> | |||
)} | |||
<td> | |||
{user.tokensCount} | |||
<ButtonIcon | |||
className="js-user-tokens spacer-left button-small" | |||
onClick={this.handleOpenTokensForm} | |||
tooltip={translate('users.update_tokens')}> | |||
<BulletListIcon /> | |||
</ButtonIcon> | |||
</td> | |||
<td className="thin nowrap text-right"> | |||
<UserActions | |||
isCurrentUser={this.props.isCurrentUser} | |||
onUpdateUsers={onUpdateUsers} | |||
user={user} | |||
/> | |||
</td> | |||
{this.state.openTokenForm && ( | |||
<TokensForm | |||
user={user} | |||
onClose={this.handleCloseTokensForm} | |||
onUpdateUsers={onUpdateUsers} | |||
/> | |||
)} | |||
</tr> | |||
); | |||
} | |||
} |
@@ -0,0 +1,71 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import { IdentityProvider, User } from '../../../api/users'; | |||
import { getBaseUrl } from '../../../helpers/urls'; | |||
interface Props { | |||
identityProvider?: IdentityProvider; | |||
user: User; | |||
} | |||
export default function UserListItemIdentity({ identityProvider, user }: Props) { | |||
return ( | |||
<td> | |||
<div> | |||
<strong className="js-user-name">{user.name}</strong> | |||
<span className="js-user-login note little-spacer-left">{user.login}</span> | |||
</div> | |||
{user.email && <div className="js-user-email little-spacer-top">{user.email}</div>} | |||
{!user.local && | |||
user.externalProvider !== 'sonarqube' && ( | |||
<ExternalProvider identityProvider={identityProvider} user={user} /> | |||
)} | |||
</td> | |||
); | |||
} | |||
export function ExternalProvider({ identityProvider, user }: Props) { | |||
if (!identityProvider) { | |||
return ( | |||
<div className="js-user-identity-provider little-spacer-top"> | |||
<span> | |||
{user.externalProvider}: {user.externalIdentity} | |||
</span> | |||
</div> | |||
); | |||
} | |||
return ( | |||
<div className="js-user-identity-provider little-spacer-top"> | |||
<div | |||
className="identity-provider" | |||
style={{ 'background-color': identityProvider.backgroundColor }}> | |||
<img | |||
alt={identityProvider.name} | |||
src={getBaseUrl() + identityProvider.iconPath} | |||
width="14" | |||
height="14" | |||
/> | |||
{user.externalIdentity} | |||
</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. | |||
*/ | |||
import * as React from 'react'; | |||
import { DeleteButton } from '../../../components/ui/buttons'; | |||
export interface Props { | |||
idx: number; | |||
scmAccount: string; | |||
onChange: (idx: number, scmAccount: string) => void; | |||
onRemove: (idx: number) => void; | |||
} | |||
export default class UserScmAccountInput extends React.PureComponent<Props> { | |||
handleChange = (event: React.SyntheticEvent<HTMLInputElement>) => | |||
this.props.onChange(this.props.idx, event.currentTarget.value); | |||
handleRemove = () => this.props.onRemove(this.props.idx); | |||
render() { | |||
return ( | |||
<div> | |||
<input | |||
maxLength={255} | |||
onChange={this.handleChange} | |||
type="text" | |||
value={this.props.scmAccount} | |||
/> | |||
<DeleteButton onClick={this.handleRemove} /> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,68 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import { translateWithParameters } from '../../../helpers/l10n'; | |||
interface Props { | |||
scmAccounts: string[]; | |||
} | |||
interface State { | |||
showMore: boolean; | |||
} | |||
const SCM_LIMIT = 3; | |||
export default class UserScmAccounts extends React.PureComponent<Props, State> { | |||
state: State = { showMore: false }; | |||
toggleShowMore = (evt: React.SyntheticEvent<HTMLAnchorElement>) => { | |||
evt.preventDefault(); | |||
this.setState(state => ({ showMore: !state.showMore })); | |||
}; | |||
render() { | |||
const { scmAccounts } = this.props; | |||
const limit = scmAccounts.length > SCM_LIMIT ? SCM_LIMIT - 1 : SCM_LIMIT; | |||
return ( | |||
<ul> | |||
{scmAccounts.slice(0, limit).map((scmAccount, idx) => ( | |||
<li key={idx} className="little-spacer-bottom"> | |||
{scmAccount} | |||
</li> | |||
))} | |||
{scmAccounts.length > SCM_LIMIT && | |||
(this.state.showMore ? ( | |||
scmAccounts.slice(limit).map((scmAccount, idx) => ( | |||
<li key={idx + limit} className="little-spacer-bottom"> | |||
{scmAccount} | |||
</li> | |||
)) | |||
) : ( | |||
<li className="little-spacer-bottom"> | |||
<a className="js-user-more-scm" href="#" onClick={this.toggleShowMore}> | |||
{translateWithParameters('more_x', scmAccounts.length - limit)} | |||
</a> | |||
</li> | |||
))} | |||
</ul> | |||
); | |||
} | |||
} |
@@ -17,50 +17,40 @@ | |||
* 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 * as React from 'react'; | |||
import { debounce } from 'lodash'; | |||
import Avatar from '../../../components/ui/Avatar'; | |||
import Select from '../../../components/controls/Select'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
import UsersSelectSearchOption from './UsersSelectSearchOption'; | |||
import UsersSelectSearchValue from './UsersSelectSearchValue'; | |||
/*:: | |||
export type Option = { | |||
login: string, | |||
name: string, | |||
email?: string, | |||
avatar?: string, | |||
groupCount?: number | |||
}; | |||
*/ | |||
/*:: | |||
type Props = { | |||
autoFocus?: boolean, | |||
excludedUsers: Array<string>, | |||
handleValueChange: Option => void, | |||
searchUsers: (string, number) => Promise<*>, | |||
selectedUser?: Option | |||
}; | |||
*/ | |||
/*:: | |||
type State = { | |||
isLoading: boolean, | |||
search: string, | |||
searchResult: Array<Option> | |||
}; | |||
*/ | |||
interface Option { | |||
login: string; | |||
name: string; | |||
email?: string; | |||
avatar?: string; | |||
} | |||
interface Props { | |||
autoFocus?: boolean; | |||
excludedUsers: string[]; | |||
handleValueChange: (option: Option) => void; | |||
searchUsers: (query: string, ps: number) => Promise<{ users: Option[] }>; | |||
selectedUser?: Option; | |||
} | |||
interface State { | |||
isLoading: boolean; | |||
search: string; | |||
searchResult: Option[]; | |||
} | |||
const LIST_SIZE = 10; | |||
const AVATAR_SIZE = 16; | |||
export default class UsersSelectSearch extends React.PureComponent { | |||
/*:: mounted: boolean; */ | |||
/*:: props: Props; */ | |||
/*:: state: State; */ | |||
export default class UsersSelectSearch extends React.PureComponent<Props, State> { | |||
mounted: boolean; | |||
constructor(props /*: Props */) { | |||
constructor(props: Props) { | |||
super(props); | |||
this.handleSearch = debounce(this.handleSearch, 250); | |||
this.state = { searchResult: [], isLoading: false, search: '' }; | |||
@@ -70,7 +60,7 @@ export default class UsersSelectSearch extends React.PureComponent { | |||
this.handleSearch(this.state.search); | |||
} | |||
componentWillReceiveProps(nextProps /*: Props */) { | |||
componentWillReceiveProps(nextProps: Props) { | |||
if (this.props.excludedUsers !== nextProps.excludedUsers) { | |||
this.handleSearch(this.state.search); | |||
} | |||
@@ -80,10 +70,10 @@ export default class UsersSelectSearch extends React.PureComponent { | |||
this.mounted = false; | |||
} | |||
filterSearchResult = ({ users } /*: { users: Array<Option> } */) => | |||
filterSearchResult = ({ users }: { users: Option[] }) => | |||
users.filter(user => !this.props.excludedUsers.includes(user.login)).slice(0, LIST_SIZE); | |||
handleSearch = (search /*: string */) => { | |||
handleSearch = (search: string) => { | |||
this.props | |||
.searchUsers(search, Math.min(this.props.excludedUsers.length + LIST_SIZE, 500)) | |||
.then(this.filterSearchResult) | |||
@@ -94,7 +84,7 @@ export default class UsersSelectSearch extends React.PureComponent { | |||
}); | |||
}; | |||
handleInputChange = (search /*: string */) => { | |||
handleInputChange = (search: string) => { | |||
if (search == null || search.length === 1) { | |||
this.setState({ search }); | |||
} else { | |||
@@ -129,3 +119,67 @@ export default class UsersSelectSearch extends React.PureComponent { | |||
); | |||
} | |||
} | |||
interface OptionProps { | |||
children?: React.ReactNode; | |||
className?: string; | |||
isFocused?: boolean; | |||
onFocus: (option: Option, evt: React.MouseEvent<HTMLDivElement>) => void; | |||
onSelect: (option: Option, evt: React.MouseEvent<HTMLDivElement>) => void; | |||
option: Option; | |||
} | |||
export class UsersSelectSearchOption extends React.PureComponent<OptionProps> { | |||
handleMouseDown = (evt: React.MouseEvent<HTMLDivElement>) => { | |||
evt.preventDefault(); | |||
evt.stopPropagation(); | |||
this.props.onSelect(this.props.option, evt); | |||
}; | |||
handleMouseEnter = (evt: React.MouseEvent<HTMLDivElement>) => { | |||
this.props.onFocus(this.props.option, evt); | |||
}; | |||
handleMouseMove = (evt: React.MouseEvent<HTMLDivElement>) => { | |||
if (this.props.isFocused) { | |||
return; | |||
} | |||
this.props.onFocus(this.props.option, evt); | |||
}; | |||
render() { | |||
const { option } = this.props; | |||
return ( | |||
<div | |||
className={this.props.className} | |||
onMouseDown={this.handleMouseDown} | |||
onMouseEnter={this.handleMouseEnter} | |||
onMouseMove={this.handleMouseMove} | |||
title={option.name}> | |||
<Avatar hash={option.avatar} name={option.name} size={AVATAR_SIZE} /> | |||
<strong className="spacer-left">{this.props.children}</strong> | |||
<span className="note little-spacer-left">{option.login}</span> | |||
</div> | |||
); | |||
} | |||
} | |||
interface ValueProps { | |||
value?: Option; | |||
children?: React.ReactNode; | |||
} | |||
export function UsersSelectSearchValue({ children, value }: ValueProps) { | |||
return ( | |||
<div className="Select-value" title={value ? value.name : ''}> | |||
{value && | |||
value.login && ( | |||
<div className="Select-value-label"> | |||
<Avatar hash={value.avatar} name={value.name} size={AVATAR_SIZE} /> | |||
<strong className="spacer-left">{children}</strong> | |||
<span className="note little-spacer-left">{value.login}</span> | |||
</div> | |||
)} | |||
</div> | |||
); | |||
} |
@@ -1,73 +0,0 @@ | |||
/* | |||
* 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 */ = 16; | |||
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}> | |||
<Avatar hash={user.avatar} name={user.name} size={AVATAR_SIZE} /> | |||
<strong className="spacer-left">{this.props.children}</strong> | |||
<span className="note little-spacer-left">{user.login}</span> | |||
</div> | |||
); | |||
} | |||
} |
@@ -1,54 +0,0 @@ | |||
/* | |||
* 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,96 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import UsersSelectSearch, { | |||
UsersSelectSearchOption, | |||
UsersSelectSearchValue | |||
} 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']; | |||
describe('UsersSelectSearch', () => { | |||
it('should render correctly', () => { | |||
const onSearch = jest.fn(() => Promise.resolve(users)); | |||
const wrapper = shallow( | |||
<UsersSelectSearch | |||
selectedUser={selectedUser} | |||
excludedUsers={excludedUsers} | |||
handleValueChange={jest.fn()} | |||
searchUsers={onSearch} | |||
/> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
const searchResult = (wrapper.instance() as UsersSelectSearch).filterSearchResult({ users }); | |||
expect(searchResult).toMatchSnapshot(); | |||
expect(wrapper.setState({ searchResult })).toMatchSnapshot(); | |||
}); | |||
}); | |||
describe('UsersSelectSearchOption', () => { | |||
it('should render correctly without all parameters', () => { | |||
const wrapper = shallow( | |||
<UsersSelectSearchOption option={selectedUser} onFocus={jest.fn()} onSelect={jest.fn()}> | |||
{selectedUser.name} | |||
</UsersSelectSearchOption> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('should render correctly with email instead of hash', () => { | |||
const wrapper = shallow( | |||
<UsersSelectSearchOption option={users[0]} onFocus={jest.fn()} onSelect={jest.fn()}> | |||
{users[0].name} | |||
</UsersSelectSearchOption> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
}); | |||
describe('UsersSelectSearchValue', () => { | |||
it('should render correctly with a user', () => { | |||
const wrapper = shallow( | |||
<UsersSelectSearchValue value={selectedUser}>{selectedUser.name}</UsersSelectSearchValue> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('should render correctly with email instead of hash', () => { | |||
const wrapper = shallow( | |||
<UsersSelectSearchValue value={users[0]}>{users[0].name}</UsersSelectSearchValue> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('should render correctly without value', () => { | |||
const wrapper = shallow(<UsersSelectSearchValue />); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
}); |
@@ -1,53 +0,0 @@ | |||
/* | |||
* 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(); | |||
}); |
@@ -1,79 +0,0 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<Select | |||
className="Select-big" | |||
clearable={false} | |||
isLoading={false} | |||
labelKey="name" | |||
noResultsText="no_results" | |||
onChange={[Function]} | |||
onInputChange={[Function]} | |||
optionComponent={[Function]} | |||
options={Array []} | |||
placeholder="" | |||
searchable={true} | |||
value={ | |||
Object { | |||
"avatar": "7daf6c79d4802916d83f6266e24850af", | |||
"login": "admin", | |||
"name": "Administrator", | |||
} | |||
} | |||
valueComponent={[Function]} | |||
valueKey="login" | |||
/> | |||
`; | |||
exports[`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[`should render correctly 3`] = ` | |||
<Select | |||
className="Select-big" | |||
clearable={false} | |||
isLoading={false} | |||
labelKey="name" | |||
noResultsText="no_results" | |||
onChange={[Function]} | |||
onInputChange={[Function]} | |||
optionComponent={[Function]} | |||
options={ | |||
Array [ | |||
Object { | |||
"email": "tester@testing.ch", | |||
"login": "test", | |||
"name": "Tester", | |||
}, | |||
Object { | |||
"email": "foo@bar.ch", | |||
"login": "foo", | |||
"name": "Foo Bar", | |||
}, | |||
] | |||
} | |||
placeholder="" | |||
searchable={true} | |||
value={ | |||
Object { | |||
"avatar": "7daf6c79d4802916d83f6266e24850af", | |||
"login": "admin", | |||
"name": "Administrator", | |||
} | |||
} | |||
valueComponent={[Function]} | |||
valueKey="login" | |||
/> | |||
`; |
@@ -0,0 +1,188 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`UsersSelectSearch should render correctly 1`] = ` | |||
<Select | |||
className="Select-big" | |||
clearable={false} | |||
isLoading={false} | |||
labelKey="name" | |||
noResultsText="no_results" | |||
onChange={[Function]} | |||
onInputChange={[Function]} | |||
optionComponent={[Function]} | |||
options={Array []} | |||
placeholder="" | |||
searchable={true} | |||
value={ | |||
Object { | |||
"avatar": "7daf6c79d4802916d83f6266e24850af", | |||
"login": "admin", | |||
"name": "Administrator", | |||
} | |||
} | |||
valueComponent={[Function]} | |||
valueKey="login" | |||
/> | |||
`; | |||
exports[`UsersSelectSearch 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[`UsersSelectSearch should render correctly 3`] = ` | |||
<Select | |||
className="Select-big" | |||
clearable={false} | |||
isLoading={false} | |||
labelKey="name" | |||
noResultsText="no_results" | |||
onChange={[Function]} | |||
onInputChange={[Function]} | |||
optionComponent={[Function]} | |||
options={ | |||
Array [ | |||
Object { | |||
"email": "tester@testing.ch", | |||
"login": "test", | |||
"name": "Tester", | |||
}, | |||
Object { | |||
"email": "foo@bar.ch", | |||
"login": "foo", | |||
"name": "Foo Bar", | |||
}, | |||
] | |||
} | |||
placeholder="" | |||
searchable={true} | |||
value={ | |||
Object { | |||
"avatar": "7daf6c79d4802916d83f6266e24850af", | |||
"login": "admin", | |||
"name": "Administrator", | |||
} | |||
} | |||
valueComponent={[Function]} | |||
valueKey="login" | |||
/> | |||
`; | |||
exports[`UsersSelectSearchOption should render correctly with email instead of hash 1`] = ` | |||
<div | |||
onMouseDown={[Function]} | |||
onMouseEnter={[Function]} | |||
onMouseMove={[Function]} | |||
title="Administrator" | |||
> | |||
<Connect(Avatar) | |||
name="Administrator" | |||
size={16} | |||
/> | |||
<strong | |||
className="spacer-left" | |||
> | |||
Administrator | |||
</strong> | |||
<span | |||
className="note little-spacer-left" | |||
> | |||
admin | |||
</span> | |||
</div> | |||
`; | |||
exports[`UsersSelectSearchOption should render correctly without all parameters 1`] = ` | |||
<div | |||
onMouseDown={[Function]} | |||
onMouseEnter={[Function]} | |||
onMouseMove={[Function]} | |||
title="Administrator" | |||
> | |||
<Connect(Avatar) | |||
hash="7daf6c79d4802916d83f6266e24850af" | |||
name="Administrator" | |||
size={16} | |||
/> | |||
<strong | |||
className="spacer-left" | |||
> | |||
Administrator | |||
</strong> | |||
<span | |||
className="note little-spacer-left" | |||
> | |||
admin | |||
</span> | |||
</div> | |||
`; | |||
exports[`UsersSelectSearchValue should render correctly with a user 1`] = ` | |||
<div | |||
className="Select-value" | |||
title="Administrator" | |||
> | |||
<div | |||
className="Select-value-label" | |||
> | |||
<Connect(Avatar) | |||
hash="7daf6c79d4802916d83f6266e24850af" | |||
name="Administrator" | |||
size={16} | |||
/> | |||
<strong | |||
className="spacer-left" | |||
> | |||
Administrator | |||
</strong> | |||
<span | |||
className="note little-spacer-left" | |||
> | |||
admin | |||
</span> | |||
</div> | |||
</div> | |||
`; | |||
exports[`UsersSelectSearchValue should render correctly with email instead of hash 1`] = ` | |||
<div | |||
className="Select-value" | |||
title="Administrator" | |||
> | |||
<div | |||
className="Select-value-label" | |||
> | |||
<Connect(Avatar) | |||
name="Administrator" | |||
size={16} | |||
/> | |||
<strong | |||
className="spacer-left" | |||
> | |||
Administrator | |||
</strong> | |||
<span | |||
className="note little-spacer-left" | |||
> | |||
admin | |||
</span> | |||
</div> | |||
</div> | |||
`; | |||
exports[`UsersSelectSearchValue should render correctly without value 1`] = ` | |||
<div | |||
className="Select-value" | |||
title="" | |||
/> | |||
`; |
@@ -1,50 +0,0 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly with email instead of hash 1`] = ` | |||
<div | |||
onMouseDown={[Function]} | |||
onMouseEnter={[Function]} | |||
onMouseMove={[Function]} | |||
title="Administrator" | |||
> | |||
<Connect(Avatar) | |||
name="Administrator" | |||
size={16} | |||
/> | |||
<strong | |||
className="spacer-left" | |||
> | |||
Administrator | |||
</strong> | |||
<span | |||
className="note little-spacer-left" | |||
> | |||
admin | |||
</span> | |||
</div> | |||
`; | |||
exports[`should render correctly without all parameters 1`] = ` | |||
<div | |||
onMouseDown={[Function]} | |||
onMouseEnter={[Function]} | |||
onMouseMove={[Function]} | |||
title="Administrator" | |||
> | |||
<Connect(Avatar) | |||
hash="7daf6c79d4802916d83f6266e24850af" | |||
name="Administrator" | |||
size={16} | |||
/> | |||
<strong | |||
className="spacer-left" | |||
> | |||
Administrator | |||
</strong> | |||
<span | |||
className="note little-spacer-left" | |||
> | |||
admin | |||
</span> | |||
</div> | |||
`; |
@@ -1,61 +0,0 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly with a user 1`] = ` | |||
<div | |||
className="Select-value" | |||
title="Administrator" | |||
> | |||
<div | |||
className="Select-value-label" | |||
> | |||
<Connect(Avatar) | |||
hash="7daf6c79d4802916d83f6266e24850af" | |||
name="Administrator" | |||
size={16} | |||
/> | |||
<strong | |||
className="spacer-left" | |||
> | |||
Administrator | |||
</strong> | |||
<span | |||
className="note little-spacer-left" | |||
> | |||
admin | |||
</span> | |||
</div> | |||
</div> | |||
`; | |||
exports[`should render correctly with email instead of hash 1`] = ` | |||
<div | |||
className="Select-value" | |||
title="Administrator" | |||
> | |||
<div | |||
className="Select-value-label" | |||
> | |||
<Connect(Avatar) | |||
name="Administrator" | |||
size={16} | |||
/> | |||
<strong | |||
className="spacer-left" | |||
> | |||
Administrator | |||
</strong> | |||
<span | |||
className="note little-spacer-left" | |||
> | |||
admin | |||
</span> | |||
</div> | |||
</div> | |||
`; | |||
exports[`should render correctly without value 1`] = ` | |||
<div | |||
className="Select-value" | |||
title="" | |||
/> | |||
`; |
@@ -17,12 +17,18 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { RouterState, IndexRouteProps } from 'react-router'; | |||
import { RouterState, IndexRouteProps, RouteComponent } from 'react-router'; | |||
const routes = [ | |||
{ | |||
getIndexRoute(_: RouterState, callback: (err: any, route: IndexRouteProps) => any) { | |||
import('./components/UsersAppContainer').then(i => callback(null, { component: i.default })); | |||
import('./UsersAppContainer').then(i => callback(null, { component: i.default })); | |||
} | |||
}, | |||
{ | |||
path: 'old', | |||
getComponent(_: RouterState, callback: (err: any, component: RouteComponent) => any) { | |||
import('./components/UsersAppContainerOld').then(i => callback(null, i.default)); | |||
} | |||
} | |||
]; |
@@ -55,7 +55,7 @@ export default Modal.extend({ | |||
this.errors = []; | |||
this.newToken = null; | |||
const tokenName = this.$('.js-generate-token-form input').val(); | |||
generateToken(tokenName, this.model.id).then( | |||
generateToken({ name: tokenName, login: this.model.id }).then( | |||
response => { | |||
this.newToken = response; | |||
this.requestTokens(); | |||
@@ -70,7 +70,10 @@ export default Modal.extend({ | |||
const token = this.tokens.find(t => t.name === `${tokenName}`); | |||
if (token) { | |||
if (token.deleting) { | |||
revokeToken(tokenName, this.model.id).then(this.requestTokens.bind(this), () => {}); | |||
revokeToken({ name: tokenName, login: this.model.id }).then( | |||
this.requestTokens.bind(this), | |||
() => {} | |||
); | |||
} else { | |||
token.deleting = true; | |||
this.render(); |
@@ -0,0 +1,35 @@ | |||
/* | |||
* 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 { memoize } from 'lodash'; | |||
import { cleanQuery, parseAsString, RawQuery, serializeString } from '../../helpers/query'; | |||
export interface Query { | |||
search: string; | |||
} | |||
export const parseQuery = memoize((urlQuery: RawQuery): Query => ({ | |||
search: parseAsString(urlQuery['search']) | |||
})); | |||
export const serializeQuery = memoize((query: Query): RawQuery => | |||
cleanQuery({ | |||
search: query.search ? serializeString(query.search) : undefined | |||
}) | |||
); |
@@ -21,7 +21,7 @@ import * as React from 'react'; | |||
import * as classNames from 'classnames'; | |||
interface Props { | |||
children?: JSX.Element; | |||
children?: JSX.Element | JSX.Element[]; | |||
className?: string; | |||
loading?: boolean; | |||
customSpinner?: JSX.Element; |
@@ -26,6 +26,7 @@ import SettingsIcon from '../icons-components/SettingsIcon'; | |||
interface Props { | |||
className?: string; | |||
children: React.ReactNode; | |||
menuClassName?: string; | |||
menuPosition?: 'left' | 'right'; | |||
small?: boolean; | |||
toggleClassName?: string; | |||
@@ -43,7 +44,7 @@ export default function ActionsDropdown({ menuPosition = 'right', ...props }: Pr | |||
<i className="icon-dropdown little-spacer-left" /> | |||
</button> | |||
<ul | |||
className={classNames('dropdown-menu', { | |||
className={classNames('dropdown-menu', props.menuClassName, { | |||
'dropdown-menu-right': menuPosition === 'right' | |||
})}> | |||
{props.children} |
@@ -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 * as React from 'react'; | |||
import { IconProps } from './types'; | |||
export default function BulletListIcon({ className, fill = 'currentColor', size = 16 }: IconProps) { | |||
return ( | |||
<svg | |||
className={className} | |||
width={size} | |||
height={size} | |||
viewBox="0 0 16 16" | |||
version="1.1" | |||
xmlnsXlink="http://www.w3.org/1999/xlink" | |||
xmlSpace="preserve"> | |||
<path | |||
style={{ fill }} | |||
d="M2.968 11.274v1.51q0 0.102-0.075 0.177t-0.177 0.075h-1.51q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h1.51q0.102 0 0.177 0.075t0.075 0.177zM2.968 8.255v1.51q0 0.102-0.075 0.177t-0.177 0.075h-1.51q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h1.51q0.102 0 0.177 0.075t0.075 0.177zM2.968 5.235v1.51q0 0.102-0.075 0.177t-0.177 0.075h-1.51q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h1.51q0.102 0 0.177 0.075t0.075 0.177zM15.045 11.274v1.51q0 0.102-0.075 0.177t-0.177 0.075h-10.568q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h10.568q0.102 0 0.177 0.075t0.075 0.177zM2.968 2.216v1.51q0 0.102-0.075 0.177t-0.177 0.075h-1.51q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h1.51q0.102 0 0.177 0.075t0.075 0.177zM15.045 8.255v1.51q0 0.102-0.075 0.177t-0.177 0.075h-10.568q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h10.568q0.102 0 0.177 0.075t0.075 0.177zM15.045 5.235v1.51q0 0.102-0.075 0.177t-0.177 0.075h-10.568q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h10.568q0.102 0 0.177 0.075t0.075 0.177zM15.045 2.216v1.51q0 0.102-0.075 0.177t-0.177 0.075h-10.568q-0.102 0-0.177-0.075t-0.075-0.177v-1.51q0-0.102 0.075-0.177t0.177-0.075h10.568q0.102 0 0.177 0.075t0.075 0.177z" | |||
/> | |||
</svg> | |||
); | |||
} |
@@ -91,9 +91,8 @@ export default class SetAssigneePopup extends React.PureComponent { | |||
}).then(this.handleSearchResult, this.props.onFail); | |||
}; | |||
searchUsers = (query /*: string */) => { | |||
searchUsers(query, LIST_SIZE).then(this.handleSearchResult, this.props.onFail); | |||
}; | |||
searchUsers = (query /*: string */) => | |||
searchUsers({ q: query, ps: LIST_SIZE }).then(this.handleSearchResult, this.props.onFail); | |||
handleSearchResult = (data /*: Object */) => { | |||
this.setState({ |
@@ -21,14 +21,16 @@ import * as React from 'react'; | |||
import * as classNames from 'classnames'; | |||
import * as theme from '../../app/theme'; | |||
import ClearIcon from '../icons-components/ClearIcon'; | |||
import './buttons.css'; | |||
import EditIcon from '../icons-components/EditIcon'; | |||
import Tooltip from '../controls/Tooltip'; | |||
import './buttons.css'; | |||
interface ButtonIconProps { | |||
children: React.ReactNode; | |||
className?: string; | |||
color?: string; | |||
onClick?: () => void; | |||
tooltip?: string; | |||
[x: string]: any; | |||
} | |||
@@ -43,8 +45,8 @@ export class ButtonIcon extends React.PureComponent<ButtonIconProps> { | |||
}; | |||
render() { | |||
const { children, className, color = theme.darkBlue, onClick, ...props } = this.props; | |||
return ( | |||
const { children, className, color = theme.darkBlue, onClick, tooltip, ...props } = this.props; | |||
const buttonComponent = ( | |||
<button | |||
className={classNames(className, 'button-icon')} | |||
onClick={this.handleClick} | |||
@@ -53,6 +55,14 @@ export class ButtonIcon extends React.PureComponent<ButtonIconProps> { | |||
{children} | |||
</button> | |||
); | |||
if (tooltip) { | |||
return ( | |||
<Tooltip overlay={tooltip} mouseEnterDelay={0.4}> | |||
{buttonComponent} | |||
</Tooltip> | |||
); | |||
} | |||
return buttonComponent; | |||
} | |||
} | |||
@@ -10,6 +10,10 @@ | |||
version "2.2.3" | |||
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5" | |||
"@types/clipboard@1.5.35": | |||
version "1.5.35" | |||
resolved "https://registry.yarnpkg.com/@types/clipboard/-/clipboard-1.5.35.tgz#bceb67a7e0aad3070468929b95a2d626405db464" | |||
"@types/d3-array@1.2.1": | |||
version "1.2.1" | |||
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-1.2.1.tgz#e489605208d46a1c9d980d2e5772fa9c75d9ec65" |
@@ -91,6 +91,7 @@ members=Members | |||
min=Min | |||
minor=Minor | |||
more=More | |||
more_x={0} more | |||
more_actions=More Actions | |||
my_favorite=My Favorite | |||
my_favorites=My Favorites |