Quellcode durchsuchen

Rewrite users page to TS and React

tags/7.0-RC1
Grégoire Aubert vor 6 Jahren
Ursprung
Commit
1fccf4779b
47 geänderte Dateien mit 2428 neuen und 538 gelöschten Zeilen
  1. 1
    0
      server/sonar-web/package.json
  2. 0
    6
      server/sonar-web/src/main/js/api/components.ts
  3. 13
    16
      server/sonar-web/src/main/js/api/user-tokens.ts
  4. 53
    17
      server/sonar-web/src/main/js/api/users.ts
  5. 4
    0
      server/sonar-web/src/main/js/app/styles/init/misc.css
  6. 23
    23
      server/sonar-web/src/main/js/apps/account/components/Password.js
  7. 5
    2
      server/sonar-web/src/main/js/apps/account/tokens-view.js
  8. 1
    1
      server/sonar-web/src/main/js/apps/issues/utils.js
  9. 2
    2
      server/sonar-web/src/main/js/apps/tutorials/onboarding/TokenStep.js
  10. 59
    0
      server/sonar-web/src/main/js/apps/users/Header.tsx
  11. 21
    24
      server/sonar-web/src/main/js/apps/users/Search.tsx
  12. 142
    0
      server/sonar-web/src/main/js/apps/users/UsersApp.tsx
  13. 15
    24
      server/sonar-web/src/main/js/apps/users/UsersAppContainer.tsx
  14. 68
    0
      server/sonar-web/src/main/js/apps/users/UsersList.tsx
  15. 95
    0
      server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx
  16. 97
    0
      server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
  17. 182
    0
      server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx
  18. 202
    0
      server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx
  19. 97
    0
      server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx
  20. 101
    0
      server/sonar-web/src/main/js/apps/users/components/TokensFormNewToken.tsx
  21. 113
    0
      server/sonar-web/src/main/js/apps/users/components/UserActions.tsx
  22. 270
    0
      server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
  23. 95
    0
      server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx
  24. 93
    0
      server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
  25. 71
    0
      server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx
  26. 48
    0
      server/sonar-web/src/main/js/apps/users/components/UserScmAccountInput.tsx
  27. 68
    0
      server/sonar-web/src/main/js/apps/users/components/UserScmAccounts.tsx
  28. 0
    0
      server/sonar-web/src/main/js/apps/users/components/UsersAppContainerOld.js
  29. 95
    41
      server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.tsx
  30. 0
    73
      server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js
  31. 0
    54
      server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js
  32. 96
    0
      server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.tsx
  33. 0
    53
      server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js
  34. 0
    79
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap
  35. 188
    0
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.tsx.snap
  36. 0
    50
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap
  37. 0
    61
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap
  38. 8
    2
      server/sonar-web/src/main/js/apps/users/routes.ts
  39. 5
    2
      server/sonar-web/src/main/js/apps/users/tokens-view.js
  40. 35
    0
      server/sonar-web/src/main/js/apps/users/utils.ts
  41. 1
    1
      server/sonar-web/src/main/js/components/common/DeferredSpinner.tsx
  42. 2
    1
      server/sonar-web/src/main/js/components/controls/ActionsDropdown.tsx
  43. 39
    0
      server/sonar-web/src/main/js/components/icons-components/BulletListIcon.tsx
  44. 2
    3
      server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js
  45. 13
    3
      server/sonar-web/src/main/js/components/ui/buttons.tsx
  46. 4
    0
      server/sonar-web/yarn.lock
  47. 1
    0
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 1
- 0
server/sonar-web/package.json Datei anzeigen

@@ -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",

+ 0
- 6
server/sonar-web/src/main/js/api/components.ts Datei anzeigen

@@ -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;

+ 13
- 16
server/sonar-web/src/main/js/api/user-tokens.ts Datei anzeigen

@@ -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);
}

+ 53
- 17
server/sonar-web/src/main/js/api/users.ts Datei anzeigen

@@ -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> {

+ 4
- 0
server/sonar-web/src/main/js/app/styles/init/misc.css Datei anzeigen

@@ -316,6 +316,10 @@ td.big-spacer-top {
cursor: not-allowed;
}

.no-select {
user-select: none;
}

.no-outline,
.no-outline:focus {
outline: none;

+ 23
- 23
server/sonar-web/src/main/js/apps/account/components/Password.js Datei anzeigen

@@ -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"

+ 5
- 2
server/sonar-web/src/main/js/apps/account/tokens-view.js Datei anzeigen

@@ -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();

+ 1
- 1
server/sonar-web/src/main/js/apps/issues/utils.js Datei anzeigen

@@ -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,

+ 2
- 2
server/sonar-web/src/main/js/apps/tutorials/onboarding/TokenStep.js Datei anzeigen

@@ -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 });

+ 59
- 0
server/sonar-web/src/main/js/apps/users/Header.tsx Datei anzeigen

@@ -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>
);
}
}

server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js → server/sonar-web/src/main/js/apps/users/Search.tsx Datei anzeigen

@@ -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>
);
}

+ 142
- 0
server/sonar-web/src/main/js/apps/users/UsersApp.tsx Datei anzeigen

@@ -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>
);
}
}

server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchOption-test.js → server/sonar-web/src/main/js/apps/users/UsersAppContainer.tsx Datei anzeigen

@@ -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);

+ 68
- 0
server/sonar-web/src/main/js/apps/users/UsersList.tsx Datei anzeigen

@@ -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">&nbsp;</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>
);
}

+ 95
- 0
server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx Datei anzeigen

@@ -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>
);
}
}

+ 97
- 0
server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx Datei anzeigen

@@ -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>
);
}
}

+ 182
- 0
server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx Datei anzeigen

@@ -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>
);
}
}

+ 202
- 0
server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx Datei anzeigen

@@ -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>
);
}
}

+ 97
- 0
server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx Datei anzeigen

@@ -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>
);
}
}

+ 101
- 0
server/sonar-web/src/main/js/apps/users/components/TokensFormNewToken.tsx Datei anzeigen

@@ -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>
);
}
}

+ 113
- 0
server/sonar-web/src/main/js/apps/users/components/UserActions.tsx Datei anzeigen

@@ -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();
}
}

+ 270
- 0
server/sonar-web/src/main/js/apps/users/components/UserForm.tsx Datei anzeigen

@@ -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>
);
}
}

+ 95
- 0
server/sonar-web/src/main/js/apps/users/components/UserGroups.tsx Datei anzeigen

@@ -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>
);
}
}

+ 93
- 0
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx Datei anzeigen

@@ -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>
);
}
}

+ 71
- 0
server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx Datei anzeigen

@@ -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>
);
}

+ 48
- 0
server/sonar-web/src/main/js/apps/users/components/UserScmAccountInput.tsx Datei anzeigen

@@ -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>
);
}
}

+ 68
- 0
server/sonar-web/src/main/js/apps/users/components/UserScmAccounts.tsx Datei anzeigen

@@ -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>
);
}
}

server/sonar-web/src/main/js/apps/users/components/UsersAppContainer.js → server/sonar-web/src/main/js/apps/users/components/UsersAppContainerOld.js Datei anzeigen


server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.js → server/sonar-web/src/main/js/apps/users/components/UsersSelectSearch.tsx Datei anzeigen

@@ -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>
);
}

+ 0
- 73
server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js Datei anzeigen

@@ -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>
);
}
}

+ 0
- 54
server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.js Datei anzeigen

@@ -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();
});

+ 96
- 0
server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearch-test.tsx Datei anzeigen

@@ -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();
});
});

+ 0
- 53
server/sonar-web/src/main/js/apps/users/components/__tests__/UsersSelectSearchValue-test.js Datei anzeigen

@@ -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();
});

+ 0
- 79
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.js.snap Datei anzeigen

@@ -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"
/>
`;

+ 188
- 0
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearch-test.tsx.snap Datei anzeigen

@@ -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=""
/>
`;

+ 0
- 50
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap Datei anzeigen

@@ -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>
`;

+ 0
- 61
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap Datei anzeigen

@@ -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=""
/>
`;

+ 8
- 2
server/sonar-web/src/main/js/apps/users/routes.ts Datei anzeigen

@@ -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));
}
}
];

+ 5
- 2
server/sonar-web/src/main/js/apps/users/tokens-view.js Datei anzeigen

@@ -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();

+ 35
- 0
server/sonar-web/src/main/js/apps/users/utils.ts Datei anzeigen

@@ -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
})
);

+ 1
- 1
server/sonar-web/src/main/js/components/common/DeferredSpinner.tsx Datei anzeigen

@@ -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;

+ 2
- 1
server/sonar-web/src/main/js/components/controls/ActionsDropdown.tsx Datei anzeigen

@@ -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}

+ 39
- 0
server/sonar-web/src/main/js/components/icons-components/BulletListIcon.tsx Datei anzeigen

@@ -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>
);
}

+ 2
- 3
server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js Datei anzeigen

@@ -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({

+ 13
- 3
server/sonar-web/src/main/js/components/ui/buttons.tsx Datei anzeigen

@@ -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;
}
}


+ 4
- 0
server/sonar-web/yarn.lock Datei anzeigen

@@ -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"

+ 1
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties Datei anzeigen

@@ -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

Laden…
Abbrechen
Speichern