diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-11-06 14:18:22 +0100 |
---|---|---|
committer | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-11-24 17:22:33 +0100 |
commit | 1fccf4779bbf4d1c125fd65ed3972b57b3c1be6e (patch) | |
tree | 95747a368747f6287e966ebd563f5c814575e4a6 | |
parent | 51c76205777a9a05783c781bad8c66e3eadef163 (diff) | |
download | sonarqube-1fccf4779bbf4d1c125fd65ed3972b57b3c1be6e.tar.gz sonarqube-1fccf4779bbf4d1c125fd65ed3972b57b3c1be6e.zip |
Rewrite users page to TS and React
47 files changed, 2428 insertions, 538 deletions
diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index febc889e9b3..640c3f00eab 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -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", diff --git a/server/sonar-web/src/main/js/api/components.ts b/server/sonar-web/src/main/js/api/components.ts index 2d03c4f0db4..6448333071f 100644 --- a/server/sonar-web/src/main/js/api/components.ts +++ b/server/sonar-web/src/main/js/api/components.ts @@ -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; diff --git a/server/sonar-web/src/main/js/api/user-tokens.ts b/server/sonar-web/src/main/js/api/user-tokens.ts index 2b98e0407cd..8fa8b241575 100644 --- a/server/sonar-web/src/main/js/api/user-tokens.ts +++ b/server/sonar-web/src/main/js/api/user-tokens.ts @@ -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); } diff --git a/server/sonar-web/src/main/js/api/users.ts b/server/sonar-web/src/main/js/api/users.ts index 2fb8213e139..f78343b5969 100644 --- a/server/sonar-web/src/main/js/api/users.ts +++ b/server/sonar-web/src/main/js/api/users.ts @@ -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> { diff --git a/server/sonar-web/src/main/js/app/styles/init/misc.css b/server/sonar-web/src/main/js/app/styles/init/misc.css index bdb9435384c..09aa7c6a906 100644 --- a/server/sonar-web/src/main/js/app/styles/init/misc.css +++ b/server/sonar-web/src/main/js/app/styles/init/misc.css @@ -316,6 +316,10 @@ td.big-spacer-top { cursor: not-allowed; } +.no-select { + user-select: none; +} + .no-outline, .no-outline:focus { outline: none; diff --git a/server/sonar-web/src/main/js/apps/account/components/Password.js b/server/sonar-web/src/main/js/apps/account/components/Password.js index f90aedc34cd..dec082bc69b 100644 --- a/server/sonar-web/src/main/js/apps/account/components/Password.js +++ b/server/sonar-web/src/main/js/apps/account/components/Password.js @@ -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" diff --git a/server/sonar-web/src/main/js/apps/account/tokens-view.js b/server/sonar-web/src/main/js/apps/account/tokens-view.js index e63f1692804..68c74f3d6c8 100644 --- a/server/sonar-web/src/main/js/apps/account/tokens-view.js +++ b/server/sonar-web/src/main/js/apps/account/tokens-view.js @@ -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(); diff --git a/server/sonar-web/src/main/js/apps/issues/utils.js b/server/sonar-web/src/main/js/apps/issues/utils.js index e0532e1e814..a044e880696 100644 --- a/server/sonar-web/src/main/js/apps/issues/utils.js +++ b/server/sonar-web/src/main/js/apps/issues/utils.js @@ -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, diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/TokenStep.js b/server/sonar-web/src/main/js/apps/tutorials/onboarding/TokenStep.js index dd72bd238e8..4a5609c3d9b 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/TokenStep.js +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/TokenStep.js @@ -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 }); diff --git a/server/sonar-web/src/main/js/apps/users/Header.tsx b/server/sonar-web/src/main/js/apps/users/Header.tsx new file mode 100644 index 00000000000..60eeb835f18 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/Header.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js b/server/sonar-web/src/main/js/apps/users/Search.tsx index 347d9359b14..42f3684df90 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js +++ b/server/sonar-web/src/main/js/apps/users/Search.tsx @@ -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> ); } diff --git a/server/sonar-web/src/main/js/apps/users/UsersApp.tsx b/server/sonar-web/src/main/js/apps/users/UsersApp.tsx new file mode 100644 index 00000000000..24c638ca7b6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/UsersApp.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js b/server/sonar-web/src/main/js/apps/users/UsersAppContainer.tsx index 3023f73918c..b5c14738d06 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js +++ b/server/sonar-web/src/main/js/apps/users/UsersAppContainer.tsx @@ -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); diff --git a/server/sonar-web/src/main/js/apps/users/UsersList.tsx b/server/sonar-web/src/main/js/apps/users/UsersList.tsx new file mode 100644 index 00000000000..a81a9441af3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/UsersList.tsx @@ -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> + ); +} diff --git a/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx new file mode 100644 index 00000000000..c9776ac8e72 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx new file mode 100644 index 00000000000..c7d616be792 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx b/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx new file mode 100644 index 00000000000..b6864aa9faa --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx new file mode 100644 index 00000000000..4d723aa723f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx new file mode 100644 index 00000000000..f94a65caf8c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensFormNewToken.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensFormNewToken.tsx new file mode 100644 index 00000000000..2c3961196e6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/TokensFormNewToken.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx new file mode 100644 index 00000000000..9c5f64fccea --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx @@ -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(); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx new file mode 100644 index 00000000000..292d13f85c8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx b/server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx new file mode 100644 index 00000000000..ee00341c44a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx new file mode 100644 index 00000000000..f6ebe464f4c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx new file mode 100644 index 00000000000..f3bcc9afa94 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx @@ -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> + ); +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserScmAccountInput.tsx b/server/sonar-web/src/main/js/apps/users/components/UserScmAccountInput.tsx new file mode 100644 index 00000000000..55a54a75217 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UserScmAccountInput.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserScmAccounts.tsx b/server/sonar-web/src/main/js/apps/users/components/UserScmAccounts.tsx new file mode 100644 index 00000000000..a5c4334e76b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/UserScmAccounts.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersAppContainer.js b/server/sonar-web/src/main/js/apps/users/components/UsersAppContainerOld.js index 3e6bbd28b32..3e6bbd28b32 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UsersAppContainer.js +++ b/server/sonar-web/src/main/js/apps/users/components/UsersAppContainerOld.js diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.tsx index b22877a618e..bbcffa160a3 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js +++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.tsx @@ -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> + ); +} diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js deleted file mode 100644 index 3fecdcc4592..00000000000 --- a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js +++ /dev/null @@ -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> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js deleted file mode 100644 index cfc0481145c..00000000000 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js +++ /dev/null @@ -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(); -}); diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.tsx new file mode 100644 index 00000000000..f69e83148fa --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.tsx @@ -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(); + }); +}); diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js deleted file mode 100644 index 357365a0826..00000000000 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js +++ /dev/null @@ -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(); -}); diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap deleted file mode 100644 index 49fe6648294..00000000000 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap +++ /dev/null @@ -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" -/> -`; diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.tsx.snap new file mode 100644 index 00000000000..f79a49c65e5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.tsx.snap @@ -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="" +/> +`; diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap deleted file mode 100644 index e75246d2acb..00000000000 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap +++ /dev/null @@ -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> -`; diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap deleted file mode 100644 index 1aad7300b2a..00000000000 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap +++ /dev/null @@ -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="" -/> -`; diff --git a/server/sonar-web/src/main/js/apps/users/routes.ts b/server/sonar-web/src/main/js/apps/users/routes.ts index 88751d8b803..a56f4adf1c1 100644 --- a/server/sonar-web/src/main/js/apps/users/routes.ts +++ b/server/sonar-web/src/main/js/apps/users/routes.ts @@ -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)); } } ]; diff --git a/server/sonar-web/src/main/js/apps/users/tokens-view.js b/server/sonar-web/src/main/js/apps/users/tokens-view.js index fb9434142c0..9cd7a95b5a8 100644 --- a/server/sonar-web/src/main/js/apps/users/tokens-view.js +++ b/server/sonar-web/src/main/js/apps/users/tokens-view.js @@ -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(); diff --git a/server/sonar-web/src/main/js/apps/users/utils.ts b/server/sonar-web/src/main/js/apps/users/utils.ts new file mode 100644 index 00000000000..0cac3aaa3c7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/utils.ts @@ -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 + }) +); diff --git a/server/sonar-web/src/main/js/components/common/DeferredSpinner.tsx b/server/sonar-web/src/main/js/components/common/DeferredSpinner.tsx index 5c434d380d8..1954b9aa2a7 100644 --- a/server/sonar-web/src/main/js/components/common/DeferredSpinner.tsx +++ b/server/sonar-web/src/main/js/components/common/DeferredSpinner.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/components/controls/ActionsDropdown.tsx b/server/sonar-web/src/main/js/components/controls/ActionsDropdown.tsx index c23f62d0f94..a21b0f533a4 100644 --- a/server/sonar-web/src/main/js/components/controls/ActionsDropdown.tsx +++ b/server/sonar-web/src/main/js/components/controls/ActionsDropdown.tsx @@ -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} diff --git a/server/sonar-web/src/main/js/components/icons-components/BulletListIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/BulletListIcon.tsx new file mode 100644 index 00000000000..87a06d91411 --- /dev/null +++ b/server/sonar-web/src/main/js/components/icons-components/BulletListIcon.tsx @@ -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> + ); +} diff --git a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js index d25651d6c6c..506531e2783 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js +++ b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js @@ -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({ diff --git a/server/sonar-web/src/main/js/components/ui/buttons.tsx b/server/sonar-web/src/main/js/components/ui/buttons.tsx index 0e65602d012..e73bdba9ca5 100644 --- a/server/sonar-web/src/main/js/components/ui/buttons.tsx +++ b/server/sonar-web/src/main/js/components/ui/buttons.tsx @@ -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; } } diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock index 7777a06fcf6..f441b835966 100644 --- a/server/sonar-web/yarn.lock +++ b/server/sonar-web/yarn.lock @@ -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" diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 7a0f19e1d94..cf5916587df 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 |