Browse Source

MMF-661 rework search (#2030)

tags/6.4-RC1
Stas Vilchik 7 years ago
parent
commit
72e45fffde
75 changed files with 2105 additions and 819 deletions
  1. 2
    1
      it/it-tests/src/test/java/it/user/BaseIdentityProviderTest.java
  2. 2
    7
      it/it-tests/src/test/java/it/user/LocalAuthenticationTest.java
  3. 10
    1
      it/it-tests/src/test/java/it/user/MyAccountPageTest.java
  4. 2
    1
      it/it-tests/src/test/java/it/user/OAuth2IdentityProviderTest.java
  5. 5
    0
      it/it-tests/src/test/java/pageobjects/LoginPage.java
  6. 0
    29
      it/it-tests/src/test/resources/user/BaseIdentityProviderTest/authenticate_user.html
  7. 0
    5
      it/it-tests/src/test/resources/user/LocalAuthenticationTest/force-authentication.html
  8. 1
    6
      it/it-tests/src/test/resources/user/LocalAuthenticationTest/login_successful.html
  9. 0
    5
      it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_direct_login.html
  10. 0
    5
      it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_indirect_login.html
  11. 0
    5
      it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_with_parameters_after_direct_login.html
  12. 2
    2
      it/it-tests/src/test/resources/user/LocalAuthenticationTest/should_not_be_unlogged_when_going_to_login_page.html
  13. 0
    110
      it/it-tests/src/test/resources/user/MyAccountPageTest/should_change_password.html
  14. 0
    29
      it/it-tests/src/test/resources/user/OAuth2IdentityProviderTest/authenticate_user.html
  15. 1
    1
      server/sonar-web/flow-typed/npm/lodash_v4.x.x.js
  16. 39
    2
      server/sonar-web/src/main/js/api/components.js
  17. 10
    3
      server/sonar-web/src/main/js/app/components/RecentHistory.js
  18. 1
    1
      server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js
  19. 13
    5
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js
  20. 0
    116
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js
  21. 419
    0
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js
  22. 113
    0
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js
  23. 2
    3
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js
  24. 0
    303
      server/sonar-web/src/main/js/app/components/nav/global/SearchView.js
  25. 140
    0
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js
  26. 109
    0
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js
  27. 262
    0
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap
  28. 323
    0
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap
  29. 0
    1
      server/sonar-web/src/main/js/app/components/nav/templates/nav-search-empty.hbs
  30. 0
    28
      server/sonar-web/src/main/js/app/components/nav/templates/nav-search-item.hbs
  31. 0
    8
      server/sonar-web/src/main/js/app/components/nav/templates/nav-search.hbs
  32. 1
    6
      server/sonar-web/src/main/js/app/styles/boxed-group.css
  33. 1
    1
      server/sonar-web/src/main/js/apps/account/components/UserCard.js
  34. 1
    0
      server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js
  35. 7
    1
      server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js
  36. 4
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap
  37. 1
    1
      server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js
  38. 3
    0
      server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap
  39. 6
    1
      server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.js
  40. 1
    1
      server/sonar-web/src/main/js/apps/project-admin/key/BulkUpdate.js
  41. 1
    1
      server/sonar-web/src/main/js/apps/project-admin/key/Key.js
  42. 1
    1
      server/sonar-web/src/main/js/apps/sessions/components/Logout.js
  43. 1
    1
      server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js
  44. 1
    1
      server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js
  45. 2
    0
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap
  46. 2
    0
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap
  47. 3
    5
      server/sonar-web/src/main/js/apps/users/templates/users-list-item.hbs
  48. 23
    12
      server/sonar-web/src/main/js/components/common/ClockIcon.js
  49. 83
    0
      server/sonar-web/src/main/js/components/common/DeferredSpinner.js
  50. 44
    0
      server/sonar-web/src/main/js/components/common/FavoriteIcon.js
  51. 56
    0
      server/sonar-web/src/main/js/components/common/__tests__/DeferredSpinner-test.js
  52. 92
    0
      server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DeferredSpinner-test.js.snap
  53. 6
    19
      server/sonar-web/src/main/js/components/controls/FavoriteBase.js
  54. 6
    19
      server/sonar-web/src/main/js/components/controls/FavoriteBaseStateless.js
  55. 2
    2
      server/sonar-web/src/main/js/components/controls/__tests__/FavoriteBase-test.js
  56. 27
    0
      server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/FavoriteBase-test.js.snap
  57. 6
    1
      server/sonar-web/src/main/js/components/issue/components/IssueAssign.js
  58. 6
    1
      server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.js
  59. 3
    0
      server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.js.snap
  60. 3
    0
      server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.js.snap
  61. 6
    3
      server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.js
  62. 7
    7
      server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js
  63. 1
    0
      server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js
  64. 1
    0
      server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.js.snap
  65. 56
    1
      server/sonar-web/src/main/js/components/ui/Avatar.js
  66. 11
    22
      server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.js
  67. 42
    0
      server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.js.snap
  68. 10
    14
      server/sonar-web/src/main/js/helpers/handlebars/avatarHelper.js
  69. 13
    0
      server/sonar-web/src/main/js/helpers/testUtils.js
  70. 28
    0
      server/sonar-web/src/main/less/components/menu.less
  71. 68
    17
      server/sonar-web/src/main/less/components/navbar.less
  72. 1
    0
      server/sonar-web/src/main/less/components/tooltips.less
  73. 3
    3
      server/sonar-web/src/main/less/init/icons.less
  74. 6
    0
      server/sonar-web/src/main/less/init/type.less
  75. 3
    1
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 2
- 1
it/it-tests/src/test/java/it/user/BaseIdentityProviderTest.java View File

@@ -32,6 +32,7 @@ import org.junit.Test;
import org.sonarqube.ws.client.GetRequest;
import org.sonarqube.ws.client.WsClient;
import org.sonarqube.ws.client.user.CreateRequest;
import pageobjects.Navigation;
import util.user.UserRule;
import util.user.Users;

@@ -103,7 +104,7 @@ public class BaseIdentityProviderTest {
enablePlugin();
setUserCreatedByAuthPlugin(USER_LOGIN, USER_PROVIDER_ID, USER_NAME, USER_EMAIL);

runSelenese(ORCHESTRATOR, "/user/BaseIdentityProviderTest/authenticate_user.html");
Navigation.get(ORCHESTRATOR).openLogin().useOAuth2().shouldBeLoggedIn();

userRule.verifyUserExists(USER_LOGIN, USER_NAME, USER_EMAIL);
}

+ 2
- 7
it/it-tests/src/test/java/it/user/LocalAuthenticationTest.java View File

@@ -98,13 +98,8 @@ public class LocalAuthenticationTest {
@Test
public void log_in_with_correct_credentials_then_log_out() {
nav.shouldNotBeLoggedIn();

Navigation page = nav.logIn().submitCredentials(LOGIN, "123456");
page.getRightBar().shouldHave(Condition.text(LOGIN));
nav.shouldBeLoggedIn();

nav.logOut();
nav.shouldNotBeLoggedIn();
nav.logIn().submitCredentials(LOGIN, "123456").shouldBeLoggedIn();
nav.logOut().shouldNotBeLoggedIn();
}

@Test

+ 10
- 1
it/it-tests/src/test/java/it/user/MyAccountPageTest.java View File

@@ -32,6 +32,8 @@ import org.sonarqube.ws.client.PostRequest;
import org.sonarqube.ws.client.WsClient;
import pageobjects.Navigation;

import static com.codeborne.selenide.Condition.visible;
import static com.codeborne.selenide.Selenide.$;
import static util.ItUtils.newAdminWsClient;
import static util.ItUtils.projectDir;
import static util.selenium.Selenese.runSelenese;
@@ -67,7 +69,14 @@ public class MyAccountPageTest {

@Test
public void should_change_password() throws Exception {
runSelenese(orchestrator, "/user/MyAccountPageTest/should_change_password.html");
nav.openLogin().submitCredentials("account-user", "password").shouldBeLoggedIn();
nav.open("/account/security");
$("#old_password").val("password");
$("#password").val("new_password");
$("#password_confirmation").val("new_password");
$("#change-password").click();
$(".alert-success").shouldBe(visible);
nav.logOut().logIn().submitCredentials("account-user", "new_password").shouldBeLoggedIn();
}

@Test

+ 2
- 1
it/it-tests/src/test/java/it/user/OAuth2IdentityProviderTest.java View File

@@ -36,6 +36,7 @@ import org.sonarqube.ws.client.GetRequest;
import org.sonarqube.ws.client.WsClient;
import org.sonarqube.ws.client.WsResponse;
import org.sonarqube.ws.client.user.CreateRequest;
import pageobjects.Navigation;
import util.user.UserRule;
import util.user.Users;

@@ -112,7 +113,7 @@ public class OAuth2IdentityProviderTest {
simulateRedirectionToCallback();
enablePlugin();

runSelenese(ORCHESTRATOR,"/user/OAuth2IdentityProviderTest/authenticate_user.html");
Navigation.get(ORCHESTRATOR).openLogin().useOAuth2().shouldBeLoggedIn();

userRule.verifyUserExists(USER_LOGIN, USER_NAME, USER_EMAIL);
}

+ 5
- 0
it/it-tests/src/test/java/pageobjects/LoginPage.java View File

@@ -40,6 +40,11 @@ public class LoginPage {
return submitCredentials("admin", "admin");
}

public Navigation useOAuth2() {
$(".oauth-providers a").click();
return page(Navigation.class);
}

public LoginPage submitWrongCredentials(String login, String password) {
$("#login").val(login);
$("#password").val(password);

+ 0
- 29
it/it-tests/src/test/resources/user/BaseIdentityProviderTest/authenticate_user.html View File

@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">

<body>
<table cellpadding="1" cellspacing="1" border="1">
<tr>
<td>open</td>
<td>/sessions/new</td>
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>content</td>
<td>*Log in with Fake base identity provider*</td>
</tr>
<tr>
<td>click</td>
<td>css=.oauth-providers a</td>
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>id=global-navigation</td>
<td>*John*</td>
</tr>
</table>
</body>
</html>

+ 0
- 5
it/it-tests/src/test/resources/user/LocalAuthenticationTest/force-authentication.html View File

@@ -48,11 +48,6 @@
<td>css=.js-user-authenticated</td>
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>css=.navbar</td>
<td>*Administrator*</td>
</tr>
<tr>
<td>open</td>
<td>/sessions/logout</td>

+ 1
- 6
it/it-tests/src/test/resources/user/LocalAuthenticationTest/login_successful.html View File

@@ -43,14 +43,9 @@
<td>css=.js-user-authenticated</td>
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>css=.navbar</td>
<td>*Administrator*</td>
</tr>
<tr>
<td>click</td>
<td>Link=Administrator</td>
<td>css=.js-user-authenticated</td>
<td></td>
</tr>
<tr>

+ 0
- 5
it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_direct_login.html View File

@@ -48,11 +48,6 @@
<td>css=.js-user-authenticated</td>
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>css=.navbar</td>
<td>*Administrator*</td>
</tr>
<tr>
<td>assertLocation</td>
<td>glob:*/settings?category=general*</td>

+ 0
- 5
it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_indirect_login.html View File

@@ -47,11 +47,6 @@
<td>css=.js-user-authenticated</td>
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>css=.navbar</td>
<td>*Administrator*</td>
</tr>
<tr>
<td>assertLocation</td>
<td>*/settings</td>

+ 0
- 5
it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_with_parameters_after_direct_login.html View File

@@ -57,11 +57,6 @@
<td>css=.js-user-authenticated</td>
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>css=.navbar</td>
<td>*Administrator*</td>
</tr>
<tr>
<td>assertLocation</td>
<td>*/projects?gate=OK&reliability=1&security=1</td>

+ 2
- 2
it/it-tests/src/test/resources/user/LocalAuthenticationTest/should_not_be_unlogged_when_going_to_login_page.html View File

@@ -54,9 +54,9 @@
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>waitForElementPresent</td>
<td>css=.js-user-authenticated</td>
<td>*simple-user*</td>
<td></td>
</tr>
</tbody>
</table>

+ 0
- 110
it/it-tests/src/test/resources/user/MyAccountPageTest/should_change_password.html View File

@@ -1,110 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head profile="http://selenium-ide.openqa.org/profiles/test-case">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<link rel="selenium.base" href="http://localhost:49506"/>
<title>should_change_password</title>
</head>
<body>
<table cellpadding="1" cellspacing="1" border="1">
<thead>
<tr>
<td rowspan="1" colspan="3">should_change_password</td>
</tr>
</thead>
<tbody>
<tr>
<td>open</td>
<td>/sonar/sessions/login</td>
<td></td>
</tr>
<tr>
<td>type</td>
<td>id=login</td>
<td>account-user</td>
</tr>
<tr>
<td>type</td>
<td>id=password</td>
<td>password</td>
</tr>
<tr>
<td>clickAndWait</td>
<td>commit</td>
<td></td>
</tr>
<tr>
<td>waitForElementPresent</td>
<td>css=.js-user-authenticated</td>
<td></td>
</tr>
<tr>
<td>open</td>
<td>/sonar/account/security</td>
<td></td>
</tr>
<tr>
<td>waitForElementPresent</td>
<td>id=change-password</td>
<td></td>
</tr>
<tr>
<td>type</td>
<td>id=old_password</td>
<td>password</td>
</tr>
<tr>
<td>type</td>
<td>id=password</td>
<td>new_password</td>
</tr>
<tr>
<td>type</td>
<td>id=password_confirmation</td>
<td>new_password</td>
</tr>
<tr>
<td>click</td>
<td>id=change-password</td>
<td></td>
</tr>
<tr>
<td>waitForElementPresent</td>
<td>css=.alert-success</td>
<td></td>
</tr>
<tr>
<td>open</td>
<td>/sonar/sessions/logout</td>
<td></td>
</tr>
<tr>
<td>open</td>
<td>/sonar/sessions/login</td>
<td></td>
</tr>
<tr>
<td>type</td>
<td>id=login</td>
<td>account-user</td>
</tr>
<tr>
<td>type</td>
<td>id=password</td>
<td>new_password</td>
</tr>
<tr>
<td>clickAndWait</td>
<td>commit</td>
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>id=global-navigation</td>
<td>*User With Account*</td>
</tr>
</tbody>
</table>
</body>
</html>

+ 0
- 29
it/it-tests/src/test/resources/user/OAuth2IdentityProviderTest/authenticate_user.html View File

@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">

<body>
<table cellpadding="1" cellspacing="1" border="1">
<tr>
<td>open</td>
<td>/sessions/new</td>
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>content</td>
<td>*Log in with Fake oauth2 identity provider*</td>
</tr>
<tr>
<td>click</td>
<td>css=.oauth-providers a</td>
<td></td>
</tr>
<tr>
<td>waitForText</td>
<td>id=global-navigation</td>
<td>*John*</td>
</tr>
</table>
</body>
</html>

+ 1
- 1
server/sonar-web/flow-typed/npm/lodash_v4.x.x.js View File

@@ -193,7 +193,7 @@ declare module 'lodash' {
forEach<T: Object>(object: T, iteratee?: OIteratee<T>): T;
forEachRight<T>(array: ?Array<T>, iteratee?: Iteratee<T>): Array<T>;
forEachRight<T: Object>(object: T, iteratee?: OIteratee<T>): T;
groupBy<V, T>(array: ?Array<T>, iteratee?: ValueOnlyIteratee<T>): {[key: V]: ?Array<T>};
groupBy<V, T>(array: ?Array<T>, iteratee?: ValueOnlyIteratee<T>): {[key: V]: Array<T>};
groupBy<V, A, T: {[id: string]: A}>(object: T, iteratee?: ValueOnlyIteratee<A>): {[key: V]: ?Array<A>};
includes<T>(array: ?Array<T>, value: T, fromIndex?: number): bool;
includes<T: Object>(object: T, value: any, fromIndex?: number): bool;

+ 39
- 2
server/sonar-web/src/main/js/api/components.js View File

@@ -166,8 +166,45 @@ export function bulkChangeKey(project: string, from: string, to: string, dryRun?
return postJSON(url, data);
}

export const getSuggestions = (query: string): Promise<Object> =>
getJSON('/api/components/suggestions', { s: query });
export type SuggestionsResponse = {
organizations: Array<{
key: string,
name: string
}>,
projects: Array<{
key: string,
name: string
}>,
results: Array<{
items: Array<{
isFavorite: boolean,
isRecentlyBrowsed: boolean,
key: string,
match: string,
name: string,
organization: string,
project: string
}>,
more: number,
q: string
}>,
warning?: string
};

export const getSuggestions = (
query: string,
recentlyBrowsed?: Array<string>,
more?: string
): Promise<SuggestionsResponse> => {
const data: Object = { s: query };
if (recentlyBrowsed) {
data.recentlyBrowsed = recentlyBrowsed.join();
}
if (more) {
data.more = more;
}
return getJSON('/api/components/suggestions', data);
};

export const getComponentForSourceViewer = (component: string): Promise<*> =>
getJSON('/api/components/app', { component });

server/sonar-web/src/main/js/app/components/nav/component/RecentHistory.js → server/sonar-web/src/main/js/app/components/RecentHistory.js View File

@@ -30,7 +30,10 @@ type History = Array<{

export default class RecentHistory {
static get(): History {
let history = localStorage.getItem(STORAGE_KEY);
if (!window.localStorage) {
return [];
}
let history = window.localStorage.getItem(STORAGE_KEY);
if (history == null) {
history = [];
} else {
@@ -45,11 +48,15 @@ export default class RecentHistory {
}

static set(newHistory: History): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory));
if (window.localStorage) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory));
}
}

static clear(): void {
localStorage.removeItem(STORAGE_KEY);
if (window.localStorage) {
window.localStorage.removeItem(STORAGE_KEY);
}
}

static add(

+ 1
- 1
server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js View File

@@ -22,7 +22,7 @@ import ComponentNavFavorite from './ComponentNavFavorite';
import ComponentNavBreadcrumbs from './ComponentNavBreadcrumbs';
import ComponentNavMeta from './ComponentNavMeta';
import ComponentNavMenu from './ComponentNavMenu';
import RecentHistory from './RecentHistory';
import RecentHistory from '../../RecentHistory';
import { TooltipsContainer } from '../../../../components/mixins/tooltips-mixin';
import { getTasksForComponent } from '../../../../api/ce';
import { STATUSES } from '../../../../apps/background-tasks/constants';

+ 13
- 5
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js View File

@@ -22,7 +22,7 @@ import { connect } from 'react-redux';
import GlobalNavBranding from './GlobalNavBranding';
import GlobalNavMenu from './GlobalNavMenu';
import GlobalNavUser from './GlobalNavUser';
import GlobalNavSearch from './GlobalNavSearch';
import GlobalNavSearchForm from './GlobalNavSearchForm';
import ShortcutsHelpView from './ShortcutsHelpView';
import { getCurrentUser, getAppState } from '../../../../store/rootReducer';

@@ -54,6 +54,7 @@ class GlobalNav extends React.PureComponent {
};

render() {
/* eslint-disable max-len */
return (
<nav className="navbar navbar-global page-container" id="global-navigation">
<div className="container">
@@ -62,13 +63,20 @@ class GlobalNav extends React.PureComponent {
<GlobalNavMenu {...this.props} />

<ul className="nav navbar-nav navbar-right">
<GlobalNavUser {...this.props} />
<GlobalNavSearch {...this.props} />
<GlobalNavSearchForm {...this.props} />
<li>
<a onClick={this.openHelp} href="#">
<i className="icon-help navbar-icon" />
<a className="navbar-help" onClick={this.openHelp} href="#">
<svg width="16" height="16">
<g transform="matrix(0.0364583,0,0,0.0364583,1,-0.166667)">
<path
fill="#fff"
d="M224,344L224,296C224,293.667 223.25,291.75 221.75,290.25C220.25,288.75 218.333,288 216,288L168,288C165.667,288 163.75,288.75 162.25,290.25C160.75,291.75 160,293.667 160,296L160,344C160,346.333 160.75,348.25 162.25,349.75C163.75,351.25 165.667,352 168,352L216,352C218.333,352 220.25,351.25 221.75,349.75C223.25,348.25 224,346.333 224,344ZM288,176C288,161.333 283.375,147.75 274.125,135.25C264.875,122.75 253.333,113.083 239.5,106.25C225.667,99.417 211.5,96 197,96C156.5,96 125.583,113.75 104.25,149.25C101.75,153.25 102.417,156.75 106.25,159.75L139.25,184.75C140.417,185.75 142,186.25 144,186.25C146.667,186.25 148.75,185.25 150.25,183.25C159.083,171.917 166.25,164.25 171.75,160.25C177.417,156.25 184.583,154.25 193.25,154.25C201.25,154.25 208.375,156.417 214.625,160.75C220.875,165.083 224,170 224,175.5C224,181.833 222.333,186.917 219,190.75C215.667,194.583 210,198.333 202,202C191.5,206.667 181.875,213.875 173.125,223.625C164.375,233.375 160,243.833 160,255L160,264C160,266.333 160.75,268.25 162.25,269.75C163.75,271.25 165.667,272 168,272L216,272C218.333,272 220.25,271.25 221.75,269.75C223.25,268.25 224,266.333 224,264C224,260.833 225.792,256.708 229.375,251.625C232.958,246.542 237.5,242.417 243,239.25C248.333,236.25 252.417,233.875 255.25,232.125C258.083,230.375 261.917,227.458 266.75,223.375C271.583,219.292 275.292,215.292 277.875,211.375C280.458,207.458 282.792,202.417 284.875,196.25C286.958,190.083 288,183.333 288,176ZM384,224C384,258.833 375.417,290.958 358.25,320.375C341.083,349.792 317.792,373.083 288.375,390.25C258.958,407.417 226.833,416 192,416C157.167,416 125.042,407.417 95.625,390.25C66.208,373.083 42.917,349.792 25.75,320.375C8.583,290.958 0,258.833 0,224C0,189.167 8.583,157.042 25.75,127.625C42.917,98.208 66.208,74.917 95.625,57.75C125.042,40.583 157.167,32 192,32C226.833,32 258.958,40.583 288.375,57.75C317.792,74.917 341.083,98.208 358.25,127.625C375.417,157.042 384,189.167 384,224Z"
/>
</g>
</svg>
</a>
</li>
<GlobalNavUser {...this.props} />
</ul>
</div>
</nav>

+ 0
- 116
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js View File

@@ -1,116 +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 Backbone from 'backbone';
import React from 'react';
import { connect } from 'react-redux';
import key from 'keymaster';
import SearchView from './SearchView';
import { getCurrentUser } from '../../../../store/rootReducer';

function contains(root, node) {
while (node) {
if (node === root) {
return true;
}
node = node.parentNode;
}
return false;
}

class GlobalNavSearch extends React.PureComponent {
state = { open: false };

componentDidMount() {
key('s', () => {
const isModalOpen = document.querySelector('html').classList.contains('modal-open');
if (!isModalOpen) {
this.openSearch();
}
return false;
});
}

componentWillUnmount() {
this.closeSearch();
key.unbind('s');
}

openSearch = () => {
document.addEventListener('click', this.onClickOutside);
this.setState({ open: true }, this.renderSearchView);
};

closeSearch = () => {
document.removeEventListener('click', this.onClickOutside);
this.resetSearchView();
this.setState({ open: false });
};

resetSearchView = () => {
if (this.searchView) {
this.searchView.destroy();
}
};

onClick = e => {
e.preventDefault();
if (this.state.open) {
this.closeSearch();
} else {
this.openSearch();
}
};

onClickOutside = e => {
if (!contains(this.refs.dropdown, e.target)) {
this.closeSearch();
}
};

renderSearchView = () => {
const searchContainer = this.refs.container;
this.searchView = new SearchView({
model: new Backbone.Model(this.props),
hide: this.closeSearch
});
this.searchView.render().$el.appendTo(searchContainer);
};

render() {
const dropdownClassName = 'dropdown' + (this.state.open ? ' open' : '');
return (
<li ref="dropdown" className={dropdownClassName}>
<a className="navbar-search-dropdown" href="#" onClick={this.onClick}>
<i className="icon-search navbar-icon" />&nbsp;<i className="icon-dropdown" />
</a>
<div
ref="container"
className="dropdown-menu dropdown-menu-right global-navbar-search-dropdown"
/>
</li>
);
}
}

const mapStateToProps = state => ({
currentUser: getCurrentUser(state)
});

export default connect(mapStateToProps)(GlobalNavSearch);

+ 419
- 0
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js View File

@@ -0,0 +1,419 @@
/*
* 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 classNames from 'classnames';
import key from 'keymaster';
import { debounce, groupBy, keyBy, sortBy, uniqBy } from 'lodash';
import GlobalNavSearchFormComponent from './GlobalNavSearchFormComponent';
import type { Component } from './GlobalNavSearchFormComponent';
import RecentHistory from '../../RecentHistory';
import DeferredSpinner from '../../../../components/common/DeferredSpinner';
import { getSuggestions } from '../../../../api/components';
import { getFavorites } from '../../../../api/favorites';
import { translate, translateWithParameters } from '../../../../helpers/l10n';
import { scrollToElement } from '../../../../helpers/scrolling';
import { getProjectUrl } from '../../../../helpers/urls';

type Props = {|
appState: { organizationsEnabled: boolean },
currentUser: { isLoggedIn: boolean }
|};

type State = {
loading: boolean,
loadingMore: ?string,
more: { [string]: number },
open: boolean,
organizations: { [string]: { name: string } },
projects: { [string]: { name: string } },
query: string,
results: { [qualifier: string]: Array<Component> },
selected: ?string,
shortQuery: boolean
};

const ORDER = ['DEV', 'VW', 'SVW', 'TRK', 'BRC', 'FIL', 'UTS'];

export default class GlobalNavSearchForm extends React.PureComponent {
input: HTMLElement;
mounted: boolean;
node: HTMLElement;
nodes: { [string]: HTMLElement };
props: Props;
state: State;

static contextTypes = {
router: React.PropTypes.object
};

constructor(props: Props) {
super(props);
this.nodes = {};
this.search = debounce(this.search, 250);
this.fetchFavoritesAndRecentlyBrowsed = debounce(this.fetchFavoritesAndRecentlyBrowsed, 250, {
leading: true
});
this.state = {
loading: false,
loadingMore: null,
more: {},
open: false,
organizations: {},
projects: {},
query: '',
results: {},
selected: null,
shortQuery: false
};
}

componentDidMount() {
this.mounted = true;
key('s', () => {
this.input.focus();
this.openSearch();
return false;
});
this.fetchFavoritesAndRecentlyBrowsed();
}

componentWillUpdate() {
this.nodes = {};
}

componentDidUpdate(prevProps: Props, prevState: State) {
if (prevState.selected !== this.state.selected) {
this.scrollToSelected();
}
}

componentWillUnmount() {
this.mounted = false;
key.unbind('s');
window.removeEventListener('click', this.handleClickOutside);
}

handleClickOutside = (event: { target: HTMLElement }) => {
if (!this.node || !this.node.contains(event.target)) {
this.closeSearch();
}
};

openSearch = () => {
window.addEventListener('click', this.handleClickOutside);
if (!this.state.open) {
this.fetchFavoritesAndRecentlyBrowsed();
}
this.setState({ open: true });
};

closeSearch = () => {
if (this.input) {
this.input.blur();
}
window.removeEventListener('click', this.handleClickOutside);
this.setState({
more: {},
open: false,
organizations: {},
projects: {},
query: '',
results: {},
selected: null,
shortQuery: false
});
};

getPlainComponentsList = (results: { [qualifier: string]: Array<Component> }): Array<Component> =>
this.sortQualifiers(Object.keys(results)).reduce(
(components, qualifier) => [...components, ...results[qualifier]],
[]
);

mergeWithRecentlyBrowsed = (components: Array<Component>) => {
const recentlyBrowsed = RecentHistory.get().map(component => ({
...component,
isRecentlyBrowsed: true,
qualifier: component.icon.toUpperCase()
}));
return uniqBy([...components, ...recentlyBrowsed], 'key');
};

fetchFavoritesAndRecentlyBrowsed = () => {
const done = (components: Array<Component>) => {
const results = groupBy(this.mergeWithRecentlyBrowsed(components), 'qualifier');
const list = this.getPlainComponentsList(results);
this.setState({
loading: false,
more: {},
results,
selected: list.length > 0 ? list[0].key : null
});
};

if (this.props.currentUser.isLoggedIn) {
this.setState({ loading: true });
getFavorites().then(response => {
if (this.mounted) {
done(response.favorites.map(component => ({ ...component, isFavorite: true })));
}
});
} else {
done([]);
}
};

search = (query: string) => {
this.setState({ loading: true });
const recentlyBrowsed = RecentHistory.get().map(component => component.key);
getSuggestions(query, recentlyBrowsed).then(response => {
if (this.mounted) {
const results = {};
const more = {};
response.results.forEach(group => {
results[group.q] = group.items.map(item => ({ ...item, qualifier: group.q }));
more[group.q] = group.more;
});
const list = this.getPlainComponentsList(results);
this.setState(state => ({
loading: false,
more,
organizations: { ...state.organizations, ...keyBy(response.organizations, 'key') },
projects: { ...state.projects, ...keyBy(response.projects, 'key') },
results,
selected: list.length > 0 ? list[0].key : null,
shortQuery: response.warning === 'short_input'
}));
}
});
};

searchMore = (qualifier: string) => {
this.setState({ loading: true, loadingMore: qualifier });
const recentlyBrowsed = RecentHistory.get().map(component => component.key);
getSuggestions(this.state.query, recentlyBrowsed, qualifier).then(response => {
if (this.mounted) {
const group = response.results.find(group => group.q === qualifier);
const moreResults = (group ? group.items : []).map(item => ({ ...item, qualifier }));
this.setState(state => ({
loading: false,
loadingMore: null,
more: { ...state.more, [qualifier]: 0 },
organizations: { ...state.organizations, ...keyBy(response.organizations, 'key') },
projects: { ...state.projects, ...keyBy(response.projects, 'key') },
results: {
...state.results,
[qualifier]: uniqBy([...state.results[qualifier], ...moreResults], 'key')
}
}));
}
});
};

handleQueryChange = (event: { currentTarget: HTMLInputElement }) => {
const query = event.currentTarget.value;
this.setState({ query, shortQuery: query.length === 1 });
if (query.length === 0) {
this.fetchFavoritesAndRecentlyBrowsed();
} else if (query.length >= 2) {
this.search(query);
}
};

selectPrevious = () => {
this.setState((state: State) => {
const list = this.getPlainComponentsList(state.results);
const index = list.findIndex(component => component.key === state.selected);
return index > 0 ? { selected: list[index - 1].key } : undefined;
});
};

selectNext = () => {
this.setState((state: State) => {
const list = this.getPlainComponentsList(state.results);
const index = list.findIndex(component => component.key === state.selected);
return index >= 0 && index < list.length - 1 ? { selected: list[index + 1].key } : undefined;
});
};

openSelected = () => {
if (this.state.selected) {
this.context.router.push(getProjectUrl(this.state.selected));
this.closeSearch();
}
};

scrollToSelected = () => {
if (this.state.selected) {
const node = this.nodes[this.state.selected];
if (node) {
scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node });
}
}
};

handleKeyDown = (event: KeyboardEvent) => {
switch (event.keyCode) {
case 13:
event.preventDefault();
this.openSelected();
return;
case 27:
event.preventDefault();
this.closeSearch();
return;
case 38:
event.preventDefault();
this.selectPrevious();
return;
case 40:
event.preventDefault();
this.selectNext();
return;
}
};

handleSelect = (selected: string) => {
this.setState({ selected });
};

handleMoreClick = (event: MouseEvent & { currentTarget: HTMLElement }) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.blur();
const { qualifier } = event.currentTarget.dataset;
this.searchMore(qualifier);
};

sortQualifiers = (qualifiers: Array<string>) =>
sortBy(qualifiers, qualifier => ORDER.indexOf(qualifier));

innerRef = (component: string, node: HTMLElement) => {
this.nodes[component] = node;
};

renderComponent = (component: Component) => (
<GlobalNavSearchFormComponent
appState={this.props.appState}
component={component}
innerRef={this.innerRef}
key={component.key}
onClose={this.closeSearch}
onSelect={this.handleSelect}
organizations={this.state.organizations}
projects={this.state.projects}
selected={this.state.selected === component.key}
/>
);

renderComponents = () => {
const qualifiers = Object.keys(this.state.results);
const renderedComponents = [];

this.sortQualifiers(qualifiers).forEach(qualifier => {
const components = this.state.results[qualifier];

if (components.length > 0 && renderedComponents.length > 0) {
renderedComponents.push(<li key={`divider-${qualifier}`} className="divider" />);
}

if (components.length > 0) {
renderedComponents.push(
<li key={`header-${qualifier}`} className="dropdown-header">
{translate('qualifiers', qualifier)}
</li>
);
}

components.forEach(component => {
renderedComponents.push(this.renderComponent(component));
});

const more = this.state.more[qualifier];
if (more != null && more > 0) {
renderedComponents.push(
<li key={`more-${qualifier}`} className="menu-footer">
<DeferredSpinner
className="navbar-search-icon"
loading={this.state.loadingMore === qualifier}>
<a data-qualifier={qualifier} href="#" onClick={this.handleMoreClick}>
{translate('show_more')}
</a>
</DeferredSpinner>
</li>
);
}
});

return renderedComponents;
};

render() {
const dropdownClassName = classNames('dropdown', 'navbar-search', { open: this.state.open });

return (
<li className={dropdownClassName}>
<DeferredSpinner className="navbar-search-icon" loading={this.state.loading}>
<i className="navbar-search-icon icon-search" />
</DeferredSpinner>

<input
autoComplete="off"
className="navbar-search-input js-search-input"
maxLength="30"
name="q"
onChange={this.handleQueryChange}
onClick={event => event.stopPropagation()}
onFocus={this.openSearch}
onKeyDown={this.handleKeyDown}
ref={node => (this.input = node)}
placeholder={translate('search.placeholder')}
type="search"
value={this.state.query}
/>

{this.state.shortQuery &&
<span
className={classNames('navbar-search-input-hint', {
'is-shifted': this.state.query.length > 5
})}>
{translateWithParameters('select2.tooShort', 2)}
</span>}

{this.state.open &&
Object.keys(this.state.results).length > 0 &&
<div
className="dropdown-menu dropdown-menu-right global-navbar-search-dropdown"
ref={node => (this.node = node)}>
<ul className="menu">
{this.renderComponents()}
</ul>
<div
className="navbar-search-shortcut-hint"
dangerouslySetInnerHTML={{
__html: translateWithParameters('search.shortcut_hint', 's')
}}
/>
</div>}
</li>
);
}
}

+ 113
- 0
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js View File

@@ -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.
*/
// @flow
import React from 'react';
import { Link } from 'react-router';
import FavoriteIcon from '../../../../components/common/FavoriteIcon';
import QualifierIcon from '../../../../components/shared/QualifierIcon';
import ClockIcon from '../../../../components/common/ClockIcon';
import Tooltip from '../../../../components/controls/Tooltip';
import { getProjectUrl } from '../../../../helpers/urls';

export type Component = {
isFavorite?: boolean,
isRecentlyBrowsed?: boolean,
key: string,
match?: string,
name: string,
organization?: string,
project?: string,
qualifier: string
};

type Props = {|
appState: { organizationsEnabled: boolean },
component: Component,
innerRef: (string, HTMLElement) => void,
onClose: () => void,
onSelect: string => void,
organizations: { [string]: { name: string } },
projects: { [string]: { name: string } },
selected: boolean
|};

export default class GlobalNavSearchFormComponent extends React.PureComponent {
props: Props;

handleMouseEnter = () => {
this.props.onSelect(this.props.component.key);
};

renderOrganization = (component: Component) => {
if (!this.props.appState.organizationsEnabled) {
return null;
}

if (!['VW', 'SVW', 'TRK'].includes(component.qualifier) || component.organization == null) {
return null;
}

const organization = this.props.organizations[component.organization];
return organization ? <div className="pull-right text-muted-2">{organization.name}</div> : null;
};

renderProject = (component: Component) => {
if (!['BRC', 'FIL', 'UTS'].includes(component.qualifier) || component.project == null) {
return null;
}

const project = this.props.projects[component.project];
return project ? <div className="pull-right text-muted-2">{project.name}</div> : null;
};

render() {
const { component } = this.props;

return (
<li
className={this.props.selected ? 'active' : undefined}
key={component.key}
ref={node => this.props.innerRef(component.key, node)}>
<Tooltip mouseEnterDelay={1.0} overlay={component.key} placement="left">
<Link
data-key={component.key}
onClick={this.props.onClose}
onMouseEnter={this.handleMouseEnter}
to={getProjectUrl(component.key)}>

{this.renderOrganization(component)}
{this.renderProject(component)}

<span className="navbar-search-item-icons little-spacer-right">
{component.isFavorite && <FavoriteIcon favorite={true} size={12} />}
{!component.isFavorite && component.isRecentlyBrowsed && <ClockIcon size={12} />}
<QualifierIcon className="little-spacer-right" qualifier={component.qualifier} />
</span>

{component.match
? <span dangerouslySetInnerHTML={{ __html: component.match }} />
: component.name}

</Link>
</Tooltip>
</li>
);
}
}

+ 2
- 3
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js View File

@@ -54,9 +54,8 @@ class GlobalNavUser extends React.PureComponent {
const { currentUser } = this.props;
return (
<li className="dropdown js-user-authenticated">
<a className="dropdown-toggle" data-toggle="dropdown" href="#">
<Avatar email={currentUser.email} size={20} />&nbsp;
{currentUser.name}&nbsp;<i className="icon-dropdown" />
<a className="dropdown-toggle navbar-avatar" data-toggle="dropdown" href="#">
<Avatar email={currentUser.email} name={currentUser.name} size={24} />
</a>
<ul className="dropdown-menu dropdown-menu-right">
<li>

+ 0
- 303
server/sonar-web/src/main/js/app/components/nav/global/SearchView.js View File

@@ -1,303 +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 Backbone from 'backbone';
import Marionette from 'backbone.marionette';
import { debounce, sortBy } from 'lodash';
import SelectableCollectionView from '../../../../components/common/selectable-collection-view';
import SearchItemTemplate from '../templates/nav-search-item.hbs';
import EmptySearchTemplate from '../templates/nav-search-empty.hbs';
import SearchTemplate from '../templates/nav-search.hbs';
import RecentHistory from '../component/RecentHistory';
import { translate } from '../../../../helpers/l10n';
import { isUserAdmin } from '../../../../helpers/users';
import { getFavorites } from '../../../../api/favorites';
import { getSuggestions } from '../../../../api/components';
import {
getOrganization,
areThereCustomOrganizations
} from '../../../../store/organizations/utils';

type Finding = {
name: string,
url: string,
extra?: string
};

const SHOW_ORGANIZATION_FOR_QUALIFIERS = ['TRK', 'VW', 'SVW'];

const SearchItemView = Marionette.ItemView.extend({
tagName: 'li',
template: SearchItemTemplate,

select() {
this.$el.addClass('active');
},

deselect() {
this.$el.removeClass('active');
},

submit() {
this.$('a')[0].click();
},

onRender() {
this.$('[data-toggle="tooltip"]').tooltip({
container: 'body',
html: true,
placement: 'left',
delay: { show: 500, hide: 0 }
});
},

onDestroy() {
this.$('[data-toggle="tooltip"]').tooltip('destroy');
},

serializeData() {
return {
...Marionette.ItemView.prototype.serializeData.apply(this, arguments),
index: this.options.index
};
}
});

const SearchEmptyView = Marionette.ItemView.extend({
tagName: 'li',
template: EmptySearchTemplate
});

const SearchResultsView = SelectableCollectionView.extend({
className: 'menu',
tagName: 'ul',
childView: SearchItemView,
emptyView: SearchEmptyView
});

export default Marionette.LayoutView.extend({
className: 'navbar-search',
tagName: 'form',
template: SearchTemplate,

regions: {
resultsRegion: '.js-search-results'
},

events: {
submit: 'handleSubmit',
'keydown .js-search-input': 'onKeyDown',
'keyup .js-search-input': 'onKeyUp'
},

initialize() {
this.results = new Backbone.Collection();
this.favorite = [];
if (this.model.get('currentUser').isLoggedIn) {
this.fetchFavorite().then(
() => this.resetResultsToDefault(),
() => this.resetResultsToDefault()
);
} else {
this.resetResultsToDefault();
}
this.resultsView = new SearchResultsView({ collection: this.results });
this.debouncedSearch = debounce(this.search, 250);
this._bufferedValue = '';
},

onRender() {
const that = this;
this.resultsRegion.show(this.resultsView);
setTimeout(() => {
that.$('.js-search-input').focus();
}, 0);
},

onKeyDown(e) {
if (e.keyCode === 38) {
this.resultsView.selectPrev();
return false;
}
if (e.keyCode === 40) {
this.resultsView.selectNext();
return false;
}
if (e.keyCode === 13) {
this.resultsView.submitCurrent();
this.destroy();
return false;
}
if (e.keyCode === 27) {
this.options.hide();
return false;
}
},

onKeyUp() {
const value = this.$('.js-search-input').val();
if (value === this._bufferedValue) {
return;
}
this._bufferedValue = this.$('.js-search-input').val();
this.searchRequest = this.debouncedSearch(value);
},

onSubmit() {
return false;
},

fetchFavorite(): Promise<*> {
const customOrganizations = areThereCustomOrganizations();
return getFavorites().then(r => {
this.favorite = r.favorites.map(f => {
const showOrganization = customOrganizations && f.organization != null;
const organization = showOrganization ? getOrganization(f.organization) : null;
return {
url: window.baseUrl +
'/dashboard/index?id=' +
encodeURIComponent(f.key) +
window.dashboardParameters(true),
name: f.name,
icon: 'favorite',
organization
};
});
this.favorite = sortBy(this.favorite, 'name');
});
},

resetResultsToDefault() {
const recentHistory = RecentHistory.get();
const customOrganizations = areThereCustomOrganizations();
const history = recentHistory.map((historyItem, index) => {
const url =
window.baseUrl +
'/dashboard/index?id=' +
encodeURIComponent(historyItem.key) +
window.dashboardParameters(true);
const showOrganization = customOrganizations && historyItem.organization != null;
// $FlowFixMe flow doesn't check the above condition on `historyItem.organization != null`
const organization = showOrganization ? getOrganization(historyItem.organization) : null;
return {
url,
organization,
name: historyItem.name,
q: historyItem.icon,
extra: index === 0 ? translate('browsed_recently') : null
};
});
const favorite = this.favorite.slice(0, 6).map((f, index) => {
return { ...f, extra: index === 0 ? translate('favorite') : null };
});
this.results.reset([].concat(history, favorite));
},

search(q) {
if (q.length < 2) {
this.resetResultsToDefault();
return;
}
return getSuggestions(q).then(r => {
// if the input value has changed since we sent the request,
// just ignore the output, because another request already sent
if (q !== this._bufferedValue) {
return;
}

const customOrganizations = areThereCustomOrganizations();

const collection = [];
r.results.forEach(({ items, q }) => {
items.forEach((item, index) => {
const showOrganization =
customOrganizations &&
item.organization != null &&
SHOW_ORGANIZATION_FOR_QUALIFIERS.includes(q);
const organization = showOrganization ? getOrganization(item.organization) : null;
collection.push({
...item,
q,
organization,
extra: index === 0 ? translate('qualifiers', q) : null,
url: window.baseUrl + '/dashboard?id=' + encodeURIComponent(item.key)
});
});
});
this.results.reset([
...this.getNavigationFindings(q),
...this.getGlobalDashboardFindings(q),
...this.getFavoriteFindings(q),
...collection
]);
});
},

getNavigationFindings(q) {
const DEFAULT_ITEMS = [
{ name: translate('issues.page'), url: window.baseUrl + '/issues' },
{
name: translate('layout.measures'),
url: window.baseUrl + '/measures/search?qualifiers[]=TRK'
},
{ name: translate('coding_rules.page'), url: window.baseUrl + '/coding_rules' },
{ name: translate('quality_profiles.page'), url: window.baseUrl + '/profiles' },
{ name: translate('quality_gates.page'), url: window.baseUrl + '/quality_gates' }
];
const customItems: Array<Finding> = [];
if (isUserAdmin(this.model.get('currentUser'))) {
customItems.push({ name: translate('layout.settings'), url: window.baseUrl + '/settings' });
}
const findings = [].concat(DEFAULT_ITEMS, customItems).filter(f => {
return f.name.match(new RegExp(q, 'i'));
});
if (findings.length > 0) {
findings[0].extra = translate('navigation');
}
return findings.slice(0, 6);
},

getGlobalDashboardFindings(q) {
const dashboards = this.model.get('globalDashboards') || [];
const items = dashboards.map(d => {
return {
name: d.name,
url: window.baseUrl + '/dashboard/index?did=' + encodeURIComponent(d.key)
};
});
const findings = items.filter(f => {
return f.name.match(new RegExp(q, 'i'));
});
if (findings.length > 0) {
findings[0].extra = translate('dashboard.global_dashboards');
}
return findings.slice(0, 6);
},

getFavoriteFindings(q) {
const findings = this.favorite.filter(f => {
return f.name.match(new RegExp(q, 'i'));
});
if (findings.length > 0) {
findings[0].extra = translate('favorite');
}
return findings.slice(0, 6);
}
});

+ 140
- 0
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js View File

@@ -0,0 +1,140 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import React from 'react';
import { shallow, mount } from 'enzyme';
import type { ShallowWrapper } from 'enzyme';
import GlobalNavSearchForm from '../GlobalNavSearchForm';
import { elementKeydown, clickOutside } from '../../../../../helpers/testUtils';

function render(props?: Object) {
return shallow(
<GlobalNavSearchForm
appState={{ organizationsEnabled: false }}
currentUser={{ isLoggedIn: false }}
{...props}
/>
);
}

function component(key: string, qualifier: string = 'TRK') {
return { key, name: key, qualifier };
}

function next(form: ShallowWrapper, expected: string) {
elementKeydown(form.find('input'), 40);
expect(form.state().selected).toBe(expected);
}

function prev(form: ShallowWrapper, expected: string) {
elementKeydown(form.find('input'), 38);
expect(form.state().selected).toBe(expected);
}

function select(form: ShallowWrapper, expected: string) {
form.instance().handleSelect(expected);
expect(form.state().selected).toBe(expected);
}

it('renders different components and dividers between them', () => {
const form = render();
form.setState({
open: true,
results: {
TRK: [component('foo'), component('bar')],
BRC: [component('qwe', 'BRC'), component('qux', 'BRC')],
FIL: [component('zux', 'FIL')]
}
});
expect(form.find('.menu')).toMatchSnapshot();
});

it('renders "Show More" link', () => {
const form = render();
form.setState({
more: { TRK: 175, BRC: 0 },
open: true,
results: {
TRK: [component('foo'), component('bar')],
BRC: [component('qwe', 'BRC'), component('qux', 'BRC')]
}
});
expect(form.find('.menu')).toMatchSnapshot();
});

it('selects results', () => {
const form = render();
form.setState({
open: true,
results: {
TRK: [component('foo'), component('bar')],
BRC: [component('qwe', 'BRC')]
},
selected: 'foo'
});
expect(form.state().selected).toBe('foo');
next(form, 'bar');
next(form, 'qwe');
next(form, 'qwe');
prev(form, 'bar');
select(form, 'foo');
prev(form, 'foo');
});

it('opens selected on enter', () => {
const form = render();
form.setState({
open: true,
results: { TRK: [component('foo')] },
selected: 'foo'
});
const openSelected = jest.fn();
form.instance().openSelected = openSelected;
elementKeydown(form.find('input'), 13);
expect(openSelected).toBeCalled();
});

it('shows warning about short input', () => {
const form = render();
form.setState({ shortQuery: true });
expect(form.find('.navbar-search-input-hint')).toMatchSnapshot();
form.setState({ query: 'foobar x' });
expect(form.find('.navbar-search-input-hint')).toMatchSnapshot();
});

it('closes on escape', () => {
const form = render();
form.instance().openSearch();
expect(form.state().open).toBe(true);
elementKeydown(form.find('input'), 27);
expect(form.state().open).toBe(false);
});

it('closes on click outside', () => {
const form = mount(
<GlobalNavSearchForm
appState={{ organizationsEnabled: false }}
currentUser={{ isLoggedIn: false }}
/>
);
form.instance().openSearch();
expect(form.state().open).toBe(true);
clickOutside();
expect(form.state().open).toBe(false);
});

+ 109
- 0
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js View File

@@ -0,0 +1,109 @@
/*
* 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 { shallow } from 'enzyme';
import GlobalNavSearchFormComponent from '../GlobalNavSearchFormComponent';

function render(props?: Object) {
return shallow(
// $FlowFixMe
<GlobalNavSearchFormComponent
appState={{ organizationsEnabled: false }}
component={{ key: 'foo', name: 'foo', qualifier: 'TRK', organization: 'bar' }}
innerRef={jest.fn()}
onClose={jest.fn()}
onSelect={jest.fn()}
organizations={{ bar: { name: 'bar' } }}
projects={{ foo: { name: 'foo' } }}
selected={false}
{...props}
/>
);
}

it('renders selected', () => {
const wrapper = render();
expect(wrapper).toMatchSnapshot();
wrapper.setProps({ selected: true });
expect(wrapper).toMatchSnapshot();
});

it('renders match', () => {
const component = {
key: 'foo',
name: 'foo',
match: 'f<mark>o</mark>o',
qualifier: 'TRK',
organization: 'bar'
};
const wrapper = render({ component });
expect(wrapper).toMatchSnapshot();
});

it('renders favorite', () => {
const component = {
isFavorite: true,
key: 'foo',
name: 'foo',
qualifier: 'TRK',
organization: 'bar'
};
const wrapper = render({ component });
expect(wrapper).toMatchSnapshot();
});

it('renders recently browsed', () => {
const component = {
isRecentlyBrowsed: true,
key: 'foo',
name: 'foo',
qualifier: 'TRK',
organization: 'bar'
};
const wrapper = render({ component });
expect(wrapper).toMatchSnapshot();
});

it('renders projects', () => {
const component = {
isRecentlyBrowsed: true,
key: 'qwe',
name: 'qwe',
qualifier: 'BRC',
project: 'foo'
};
const wrapper = render({ component });
expect(wrapper).toMatchSnapshot();
});

it('renders organizations', () => {
const component = {
isRecentlyBrowsed: true,
key: 'foo',
name: 'foo',
qualifier: 'TRK',
organization: 'bar'
};
const wrapper = render({ appState: { organizationsEnabled: true }, component });
expect(wrapper).toMatchSnapshot();
wrapper.setProps({ appState: { organizationsEnabled: false } });
expect(wrapper).toMatchSnapshot();
});

+ 262
- 0
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap View File

@@ -0,0 +1,262 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`renders "Show More" link 1`] = `
<ul
className="menu"
>
<li
className="dropdown-header"
>
qualifiers.TRK
</li>
<GlobalNavSearchFormComponent
appState={
Object {
"organizationsEnabled": false,
}
}
component={
Object {
"key": "foo",
"name": "foo",
"qualifier": "TRK",
}
}
innerRef={[Function]}
onClose={[Function]}
onSelect={[Function]}
organizations={Object {}}
projects={Object {}}
selected={false}
/>
<GlobalNavSearchFormComponent
appState={
Object {
"organizationsEnabled": false,
}
}
component={
Object {
"key": "bar",
"name": "bar",
"qualifier": "TRK",
}
}
innerRef={[Function]}
onClose={[Function]}
onSelect={[Function]}
organizations={Object {}}
projects={Object {}}
selected={false}
/>
<li
className="menu-footer"
>
<DeferredSpinner
className="navbar-search-icon"
loading={false}
timeout={100}
>
<a
data-qualifier="TRK"
href="#"
onClick={[Function]}
>
show_more
</a>
</DeferredSpinner>
</li>
<li
className="divider"
/>
<li
className="dropdown-header"
>
qualifiers.BRC
</li>
<GlobalNavSearchFormComponent
appState={
Object {
"organizationsEnabled": false,
}
}
component={
Object {
"key": "qwe",
"name": "qwe",
"qualifier": "BRC",
}
}
innerRef={[Function]}
onClose={[Function]}
onSelect={[Function]}
organizations={Object {}}
projects={Object {}}
selected={false}
/>
<GlobalNavSearchFormComponent
appState={
Object {
"organizationsEnabled": false,
}
}
component={
Object {
"key": "qux",
"name": "qux",
"qualifier": "BRC",
}
}
innerRef={[Function]}
onClose={[Function]}
onSelect={[Function]}
organizations={Object {}}
projects={Object {}}
selected={false}
/>
</ul>
`;

exports[`renders different components and dividers between them 1`] = `
<ul
className="menu"
>
<li
className="dropdown-header"
>
qualifiers.TRK
</li>
<GlobalNavSearchFormComponent
appState={
Object {
"organizationsEnabled": false,
}
}
component={
Object {
"key": "foo",
"name": "foo",
"qualifier": "TRK",
}
}
innerRef={[Function]}
onClose={[Function]}
onSelect={[Function]}
organizations={Object {}}
projects={Object {}}
selected={false}
/>
<GlobalNavSearchFormComponent
appState={
Object {
"organizationsEnabled": false,
}
}
component={
Object {
"key": "bar",
"name": "bar",
"qualifier": "TRK",
}
}
innerRef={[Function]}
onClose={[Function]}
onSelect={[Function]}
organizations={Object {}}
projects={Object {}}
selected={false}
/>
<li
className="divider"
/>
<li
className="dropdown-header"
>
qualifiers.BRC
</li>
<GlobalNavSearchFormComponent
appState={
Object {
"organizationsEnabled": false,
}
}
component={
Object {
"key": "qwe",
"name": "qwe",
"qualifier": "BRC",
}
}
innerRef={[Function]}
onClose={[Function]}
onSelect={[Function]}
organizations={Object {}}
projects={Object {}}
selected={false}
/>
<GlobalNavSearchFormComponent
appState={
Object {
"organizationsEnabled": false,
}
}
component={
Object {
"key": "qux",
"name": "qux",
"qualifier": "BRC",
}
}
innerRef={[Function]}
onClose={[Function]}
onSelect={[Function]}
organizations={Object {}}
projects={Object {}}
selected={false}
/>
<li
className="divider"
/>
<li
className="dropdown-header"
>
qualifiers.FIL
</li>
<GlobalNavSearchFormComponent
appState={
Object {
"organizationsEnabled": false,
}
}
component={
Object {
"key": "zux",
"name": "zux",
"qualifier": "FIL",
}
}
innerRef={[Function]}
onClose={[Function]}
onSelect={[Function]}
organizations={Object {}}
projects={Object {}}
selected={false}
/>
</ul>
`;

exports[`shows warning about short input 1`] = `
<span
className="navbar-search-input-hint"
>
select2.tooShort.2
</span>
`;

exports[`shows warning about short input 2`] = `
<span
className="navbar-search-input-hint is-shifted"
>
select2.tooShort.2
</span>
`;

+ 323
- 0
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap View File

@@ -0,0 +1,323 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`renders favorite 1`] = `
<li>
<Tooltip
mouseEnterDelay={1}
overlay="foo"
placement="left"
>
<Link
data-key="foo"
onClick={[Function]}
onMouseEnter={[Function]}
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/dashboard",
"query": Object {
"id": "foo",
},
}
}
>
<span
className="navbar-search-item-icons little-spacer-right"
>
<FavoriteIcon
favorite={true}
size={12}
/>
<QualifierIcon
className="little-spacer-right"
qualifier="TRK"
/>
</span>
foo
</Link>
</Tooltip>
</li>
`;

exports[`renders match 1`] = `
<li>
<Tooltip
mouseEnterDelay={1}
overlay="foo"
placement="left"
>
<Link
data-key="foo"
onClick={[Function]}
onMouseEnter={[Function]}
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/dashboard",
"query": Object {
"id": "foo",
},
}
}
>
<span
className="navbar-search-item-icons little-spacer-right"
>
<QualifierIcon
className="little-spacer-right"
qualifier="TRK"
/>
</span>
<span
dangerouslySetInnerHTML={
Object {
"__html": "f<mark>o</mark>o",
}
}
/>
</Link>
</Tooltip>
</li>
`;

exports[`renders organizations 1`] = `
<li>
<Tooltip
mouseEnterDelay={1}
overlay="foo"
placement="left"
>
<Link
data-key="foo"
onClick={[Function]}
onMouseEnter={[Function]}
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/dashboard",
"query": Object {
"id": "foo",
},
}
}
>
<div
className="pull-right text-muted-2"
>
bar
</div>
<span
className="navbar-search-item-icons little-spacer-right"
>
<ClockIcon
size={12}
/>
<QualifierIcon
className="little-spacer-right"
qualifier="TRK"
/>
</span>
foo
</Link>
</Tooltip>
</li>
`;

exports[`renders organizations 2`] = `
<li>
<Tooltip
mouseEnterDelay={1}
overlay="foo"
placement="left"
>
<Link
data-key="foo"
onClick={[Function]}
onMouseEnter={[Function]}
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/dashboard",
"query": Object {
"id": "foo",
},
}
}
>
<span
className="navbar-search-item-icons little-spacer-right"
>
<ClockIcon
size={12}
/>
<QualifierIcon
className="little-spacer-right"
qualifier="TRK"
/>
</span>
foo
</Link>
</Tooltip>
</li>
`;

exports[`renders projects 1`] = `
<li>
<Tooltip
mouseEnterDelay={1}
overlay="qwe"
placement="left"
>
<Link
data-key="qwe"
onClick={[Function]}
onMouseEnter={[Function]}
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/dashboard",
"query": Object {
"id": "qwe",
},
}
}
>
<div
className="pull-right text-muted-2"
>
foo
</div>
<span
className="navbar-search-item-icons little-spacer-right"
>
<ClockIcon
size={12}
/>
<QualifierIcon
className="little-spacer-right"
qualifier="BRC"
/>
</span>
qwe
</Link>
</Tooltip>
</li>
`;

exports[`renders recently browsed 1`] = `
<li>
<Tooltip
mouseEnterDelay={1}
overlay="foo"
placement="left"
>
<Link
data-key="foo"
onClick={[Function]}
onMouseEnter={[Function]}
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/dashboard",
"query": Object {
"id": "foo",
},
}
}
>
<span
className="navbar-search-item-icons little-spacer-right"
>
<ClockIcon
size={12}
/>
<QualifierIcon
className="little-spacer-right"
qualifier="TRK"
/>
</span>
foo
</Link>
</Tooltip>
</li>
`;

exports[`renders selected 1`] = `
<li>
<Tooltip
mouseEnterDelay={1}
overlay="foo"
placement="left"
>
<Link
data-key="foo"
onClick={[Function]}
onMouseEnter={[Function]}
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/dashboard",
"query": Object {
"id": "foo",
},
}
}
>
<span
className="navbar-search-item-icons little-spacer-right"
>
<QualifierIcon
className="little-spacer-right"
qualifier="TRK"
/>
</span>
foo
</Link>
</Tooltip>
</li>
`;

exports[`renders selected 2`] = `
<li
className="active"
>
<Tooltip
mouseEnterDelay={1}
overlay="foo"
placement="left"
>
<Link
data-key="foo"
onClick={[Function]}
onMouseEnter={[Function]}
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/dashboard",
"query": Object {
"id": "foo",
},
}
}
>
<span
className="navbar-search-item-icons little-spacer-right"
>
<QualifierIcon
className="little-spacer-right"
qualifier="TRK"
/>
</span>
foo
</Link>
</Tooltip>
</li>
`;

+ 0
- 1
server/sonar-web/src/main/js/app/components/nav/templates/nav-search-empty.hbs View File

@@ -1 +0,0 @@
<span class="note">{{t 'no_results'}}</span>

+ 0
- 28
server/sonar-web/src/main/js/app/components/nav/templates/nav-search-item.hbs View File

@@ -1,28 +0,0 @@
{{#notNull extra}}
{{#gt index 0}}
<div class="divider"></div>
{{/gt}}
{{#if extra}}
<div class="dropdown-header">{{extra}}</div>
{{/if}}
{{/notNull}}

<a href="{{this.url}}" data-title="{{name}}<br>{{key}}" data-toggle="tooltip">
{{#if organization}}
<div class="pull-right nowrap note">
{{organization.name}}
</div>
{{/if}}

{{#if icon}}<i class="icon-{{icon}} text-text-bottom"></i>{{/if}}
{{#if q}}{{qualifierIcon q}}{{/if}}
{{#eq q 'FIL'}}
{{collapsedDirFromPath name}}{{fileFromPath name}}
{{else}}
{{#eq q 'UTS'}}
{{collapsedDirFromPath name}}{{fileFromPath name}}
{{else}}
{{name}}
{{/eq}}
{{/eq}}
</a>

+ 0
- 8
server/sonar-web/src/main/js/app/components/nav/templates/nav-search.hbs View File

@@ -1,8 +0,0 @@
<i class="navbar-search-icon icon-search"></i>

<input class="navbar-search-input js-search-input" type="search" name="q" placeholder="{{t 'search_verb'}}"
maxlength="30" autocomplete="off">

<div class="js-search-results"></div>

<div class="note navbar-search-subtitle">{{t 'search.shortcut'}}</div>

+ 1
- 6
server/sonar-web/src/main/js/app/styles/boxed-group.css View File

@@ -26,16 +26,11 @@
line-height: 24px;
}

.boxed-group-header > [class^="icon-"] {
.boxed-group-header [class^="icon-"] {
display: inline-block;
vertical-align: middle;
}

.boxed-group-header > .icon-star {
position: relative;
top: 1px;
}

.boxed-group-actions {
float: right;
margin-top: 15px;

+ 1
- 1
server/sonar-web/src/main/js/apps/account/components/UserCard.js View File

@@ -31,7 +31,7 @@ export default class UserCard extends React.PureComponent {
return (
<div className="account-user">
<div id="avatar" className="pull-left account-user-avatar">
<Avatar email={user.email} size={60} />
<Avatar email={user.email} name={user.name} size={60} />
</div>
<h1 id="name" className="pull-left">{user.name}</h1>
</div>

+ 1
- 0
server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js View File

@@ -256,6 +256,7 @@ export default class BulkChangeModal extends React.PureComponent {
className="little-spacer-right"
email={option.email}
hash={option.avatar}
name={option.label}
size={16}
/>}
{option.label}

+ 7
- 1
server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js View File

@@ -95,6 +95,7 @@ export default class AssigneeFacet extends React.PureComponent {
<Avatar
className="little-spacer-right"
hash={referencedUsers[assignee].avatar}
name={referencedUsers[assignee].name}
size={16}
/>
{referencedUsers[assignee].name}
@@ -115,7 +116,12 @@ export default class AssigneeFacet extends React.PureComponent {
return (
<span>
{option.avatar != null &&
<Avatar className="little-spacer-right" hash={option.avatar} size={16} />}
<Avatar
className="little-spacer-right"
hash={option.avatar}
name={option.label}
size={16}
/>}
{option.label}
</span>
);

+ 4
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap View File

@@ -32,6 +32,7 @@ exports[`should render 1`] = `
<Connect(Avatar)
className="little-spacer-right"
hash="avatart-foo"
name="name-foo"
size={16}
/>
name-foo
@@ -65,6 +66,7 @@ exports[`should render footer select option 1`] = `
<Connect(Avatar)
className="little-spacer-right"
hash="avatar-foo"
name="name-foo"
size={16}
/>
name-foo
@@ -117,6 +119,7 @@ exports[`should select unassigned 1`] = `
<Connect(Avatar)
className="little-spacer-right"
hash="avatart-foo"
name="name-foo"
size={16}
/>
name-foo
@@ -177,6 +180,7 @@ exports[`should select user 1`] = `
<Connect(Avatar)
className="little-spacer-right"
hash="avatart-foo"
name="name-foo"
size={16}
/>
name-foo

+ 1
- 1
server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js View File

@@ -45,7 +45,7 @@ export default class MembersListItem extends React.PureComponent {
return (
<tr>
<td className="thin nowrap">
<Avatar hash={member.avatar} email={member.email} size={AVATAR_SIZE} />
<Avatar hash={member.avatar} email={member.email} name={member.name} size={AVATAR_SIZE} />
</td>
<td className="nowrap text-middle">
<strong>{member.name}</strong>

+ 3
- 0
server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap View File

@@ -7,6 +7,7 @@ exports[`should groups at 0 if the groupCount field is not defined (just added u
>
<Connect(Avatar)
hash="7daf6c79d4802916d83f6266e24850af"
name="John Doe"
size={36}
/>
</td>
@@ -101,6 +102,7 @@ exports[`should not render actions and groups for non admin 1`] = `
>
<Connect(Avatar)
hash=""
name="Admin Istrator"
size={36}
/>
</td>
@@ -126,6 +128,7 @@ exports[`should render actions and groups for admin 1`] = `
>
<Connect(Avatar)
hash=""
name="Admin Istrator"
size={36}
/>
</td>

+ 6
- 1
server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.js View File

@@ -59,7 +59,12 @@ export default class UserHolder extends React.PureComponent {
<tr>
<td className="nowrap">
{!isCreator &&
<Avatar email={user.email} size={36} className="text-middle big-spacer-right" />}
<Avatar
email={user.email}
name={user.name}
size={36}
className="text-middle big-spacer-right"
/>}
<div className="display-inline-block text-middle">
<div>
<strong>{user.name}</strong>

+ 1
- 1
server/sonar-web/src/main/js/apps/project-admin/key/BulkUpdate.js View File

@@ -30,7 +30,7 @@ import {
closeAllGlobalMessages
} from '../../../store/globalMessages/duck';
import { reloadUpdateKeyPage } from './utils';
import RecentHistory from '../../../app/components/nav/component/RecentHistory';
import RecentHistory from '../../../app/components/RecentHistory';

class BulkUpdate extends React.PureComponent {
static propTypes = {

+ 1
- 1
server/sonar-web/src/main/js/apps/project-admin/key/Key.js View File

@@ -32,7 +32,7 @@ import {
} from '../../../store/globalMessages/duck';
import { parseError } from '../../code/utils';
import { reloadUpdateKeyPage } from './utils';
import RecentHistory from '../../../app/components/nav/component/RecentHistory';
import RecentHistory from '../../../app/components/RecentHistory';
import { getProjectAdminProjectModules, getComponent } from '../../../store/rootReducer';

class Key extends React.PureComponent {

+ 1
- 1
server/sonar-web/src/main/js/apps/sessions/components/Logout.js View File

@@ -23,7 +23,7 @@ import { connect } from 'react-redux';
import GlobalMessagesContainer from '../../../app/components/GlobalMessagesContainer';
import { doLogout } from '../../../store/rootActions';
import { translate } from '../../../helpers/l10n';
import RecentHistory from '../../../app/components/nav/component/RecentHistory';
import RecentHistory from '../../../app/components/RecentHistory';

class Logout extends React.PureComponent {
componentDidMount() {

+ 1
- 1
server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js View File

@@ -63,7 +63,7 @@ export default class UsersSelectSearchOption extends React.PureComponent {
onMouseMove={this.handleMouseMove}
title={user.name}>
<div className="little-spacer-bottom little-spacer-top">
<Avatar hash={user.avatar} email={user.email} size={AVATAR_SIZE} />
<Avatar hash={user.avatar} email={user.email} name={user.name} size={AVATAR_SIZE} />
<strong className="spacer-left">{this.props.children}</strong>
<span className="note little-spacer-left">{user.login}</span>
</div>

+ 1
- 1
server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js View File

@@ -39,7 +39,7 @@ export default class UsersSelectSearchValue extends React.PureComponent {
{user &&
user.login &&
<div className="Select-value-label">
<Avatar hash={user.avatar} email={user.email} size={AVATAR_SIZE} />
<Avatar hash={user.avatar} email={user.email} name={user.name} size={AVATAR_SIZE} />
<strong className="spacer-left">{this.props.children}</strong>
<span className="note little-spacer-left">{user.login}</span>
</div>}

+ 2
- 0
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap View File

@@ -12,6 +12,7 @@ exports[`should render correctly with email instead of hash 1`] = `
>
<Connect(Avatar)
email="admin@admin.ch"
name="Administrator"
size={20}
/>
<strong
@@ -40,6 +41,7 @@ exports[`should render correctly without all parameters 1`] = `
>
<Connect(Avatar)
hash="7daf6c79d4802916d83f6266e24850af"
name="Administrator"
size={20}
/>
<strong

+ 2
- 0
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap View File

@@ -10,6 +10,7 @@ exports[`should render correctly with a user 1`] = `
>
<Connect(Avatar)
hash="7daf6c79d4802916d83f6266e24850af"
name="Administrator"
size={20}
/>
<strong
@@ -36,6 +37,7 @@ exports[`should render correctly with email instead of hash 1`] = `
>
<Connect(Avatar)
email="admin@admin.ch"
name="Administrator"
size={20}
/>
<strong

+ 3
- 5
server/sonar-web/src/main/js/apps/users/templates/users-list-item.hbs View File

@@ -1,8 +1,6 @@
{{#ifShowAvatars}}
<td class="thin nowrap">
<div>{{avatarHelper email 36}}</div>
</td>
{{/ifShowAvatars}}
<td class="thin nowrap">
<div>{{avatarHelper email name 36}}</div>
</td>

<td>
<div>

server/sonar-web/src/main/js/helpers/handlebars/avatarHelperNew.js → server/sonar-web/src/main/js/components/common/ClockIcon.js View File

@@ -17,20 +17,31 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import Handlebars from 'handlebars/runtime';
// @flow
import React from 'react';
import classNames from 'classnames';

function gravatarServer() {
const getStore = require('../../app/utils/getStore').default;
const { getSettingValue } = require('../../store/rootReducer');
type Props = {
className?: string,
size?: number
};

const store = getStore();
return (getSettingValue(store.getState(), 'sonar.lf.gravatarServerUrl') || {}).value;
export default function ClockIcon(props: Props) {
/* eslint max-len: 0 */
return (
<svg
className={classNames('icon-clock', props.className)}
viewBox="0 0 16 16"
width={props.size}
height={props.size}>
<g fill="#fff" stroke="#ADADAD" transform="matrix(1.4 0 0 1.4 .3 .7)">
<circle cx="5.5" cy="5.2" r="5" />
<path fillRule="nonzero" d="M5.6 2.9v2.7l2-.5" />
</g>
</svg>
);
}

module.exports = function(emailHash, size) {
// double the size for high pixel density screens
const url = gravatarServer().replace('{EMAIL_MD5}', emailHash).replace('{SIZE}', size * 2);
return new Handlebars.default.SafeString(
`<img class="rounded" src="${url}" width="${size}" height="${size}" alt="">`
);
ClockIcon.defaultProps = {
size: 16
};

+ 83
- 0
server/sonar-web/src/main/js/components/common/DeferredSpinner.js View File

@@ -0,0 +1,83 @@
/*
* 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 classNames from 'classnames';

type Props = {
children?: React.Element<*>,
className?: string,
loading?: boolean,
timeout: number
};

type State = {
showSpinner: boolean
};

export default class DeferredSpinner extends React.PureComponent {
props: Props;
state: State;
timer: number;

static defaultProps = {
timeout: 100
};

constructor(props: Props) {
super(props);
this.state = { showSpinner: false };
}

componentDidMount() {
if (this.props.loading == null || this.props.loading === true) {
this.startTimer();
}
}

componentWillReceiveProps(nextProps: Props) {
if (this.props.loading === false && nextProps.loading === true) {
this.stopTimer();
this.startTimer();
}
if (this.props.loading === true && nextProps.loading === false) {
this.stopTimer();
this.setState({ showSpinner: false });
}
}

componentWillUnmount() {
this.stopTimer();
}

startTimer = () => {
this.timer = setTimeout(() => this.setState({ showSpinner: true }), this.props.timeout);
};

stopTimer = () => {
clearTimeout(this.timer);
};

render() {
return this.state.showSpinner
? <i className={classNames('spinner', this.props.className)} />
: this.props.children || null;
}
}

+ 44
- 0
server/sonar-web/src/main/js/components/common/FavoriteIcon.js View File

@@ -0,0 +1,44 @@
/*
* 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 classNames from 'classnames';

type Props = {
className?: string,
favorite: boolean,
size?: number
};

export default function FavoriteIcon(props: Props) {
/* eslint max-len: 0 */
return (
<span
className={classNames('icon-star', { 'icon-star-favorite': props.favorite }, props.className)}>
<svg width={props.size} height={props.size} viewBox="0 0 16 16">
<path d="M15.4275,5.77678C15.4275,5.90773 15.3501,6.05059 15.1953,6.20536L11.9542,9.36608L12.7221,13.8304C12.728,13.872 12.731,13.9316 12.731,14.0089C12.731,14.1339 12.6998,14.2396 12.6373,14.3259C12.5748,14.4122 12.484,14.4554 12.3649,14.4554C12.2518,14.4554 12.1328,14.4197 12.0078,14.3482L7.99888,12.2411L3.98995,14.3482C3.85901,14.4197 3.73996,14.4554 3.63281,14.4554C3.50781,14.4554 3.41406,14.4122 3.35156,14.3259C3.28906,14.2396 3.25781,14.1339 3.25781,14.0089C3.25781,13.9732 3.26377,13.9137 3.27567,13.8304L4.04353,9.36608L0.793531,6.20536C0.644719,6.04464 0.570313,5.90178 0.570313,5.77678C0.570313,5.55654 0.736979,5.41964 1.07031,5.36606L5.55245,4.71428L7.56138,0.651781C7.67447,0.407729 7.8203,0.285703 7.99888,0.285703C8.17745,0.285703 8.32328,0.407729 8.43638,0.651781L10.4453,4.71428L14.9274,5.36606C15.2608,5.41964 15.4274,5.55654 15.4274,5.77678L15.4275,5.77678Z" />
</svg>
</span>
);
}

FavoriteIcon.defaultProps = {
size: 16
};

+ 56
- 0
server/sonar-web/src/main/js/components/common/__tests__/DeferredSpinner-test.js View File

@@ -0,0 +1,56 @@
/*
* 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 { mount } from 'enzyme';
import DeferredSpinner from '../DeferredSpinner';

jest.useFakeTimers();

it('renders spinner after timeout', () => {
const spinner = mount(<DeferredSpinner />);
expect(spinner).toMatchSnapshot();
jest.runAllTimers();
expect(spinner).toMatchSnapshot();
});

it('add custom className', () => {
const spinner = mount(<DeferredSpinner className="foo" />);
jest.runAllTimers();
expect(spinner).toMatchSnapshot();
});

it('renders children before timeout', () => {
const spinner = mount(<DeferredSpinner><div>foo</div></DeferredSpinner>);
expect(spinner).toMatchSnapshot();
jest.runAllTimers();
expect(spinner).toMatchSnapshot();
});

it('is controlled by loading prop', () => {
const spinner = mount(<DeferredSpinner loading={false}><div>foo</div></DeferredSpinner>);
expect(spinner).toMatchSnapshot();
spinner.setProps({ loading: true });
expect(spinner).toMatchSnapshot();
jest.runAllTimers();
expect(spinner).toMatchSnapshot();
spinner.setProps({ loading: false });
expect(spinner).toMatchSnapshot();
});

+ 92
- 0
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DeferredSpinner-test.js.snap View File

@@ -0,0 +1,92 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`add custom className 1`] = `
<DeferredSpinner
className="foo"
timeout={100}
>
<i
className="spinner foo"
/>
</DeferredSpinner>
`;

exports[`is controlled by loading prop 1`] = `
<DeferredSpinner
loading={false}
timeout={100}
>
<div>
foo
</div>
</DeferredSpinner>
`;

exports[`is controlled by loading prop 2`] = `
<DeferredSpinner
loading={true}
timeout={100}
>
<div>
foo
</div>
</DeferredSpinner>
`;

exports[`is controlled by loading prop 3`] = `
<DeferredSpinner
loading={true}
timeout={100}
>
<i
className="spinner"
/>
</DeferredSpinner>
`;

exports[`is controlled by loading prop 4`] = `
<DeferredSpinner
loading={false}
timeout={100}
>
<div>
foo
</div>
</DeferredSpinner>
`;

exports[`renders children before timeout 1`] = `
<DeferredSpinner
timeout={100}
>
<div>
foo
</div>
</DeferredSpinner>
`;

exports[`renders children before timeout 2`] = `
<DeferredSpinner
timeout={100}
>
<i
className="spinner"
/>
</DeferredSpinner>
`;

exports[`renders spinner after timeout 1`] = `
<DeferredSpinner
timeout={100}
/>
`;

exports[`renders spinner after timeout 2`] = `
<DeferredSpinner
timeout={100}
>
<i
className="spinner"
/>
</DeferredSpinner>
`;

+ 6
- 19
server/sonar-web/src/main/js/components/controls/FavoriteBase.js View File

@@ -19,6 +19,7 @@
*/
import React from 'react';
import classNames from 'classnames';
import FavoriteIcon from '../common/FavoriteIcon';

export default class FavoriteBase extends React.PureComponent {
static propTypes = {
@@ -67,27 +68,13 @@ export default class FavoriteBase extends React.PureComponent {
});
}

renderSVG() {
/* eslint max-len: 0 */
return (
<svg width="16" height="16">
<path d="M15.4275,5.77678C15.4275,5.90773 15.3501,6.05059 15.1953,6.20536L11.9542,9.36608L12.7221,13.8304C12.728,13.872 12.731,13.9316 12.731,14.0089C12.731,14.1339 12.6998,14.2396 12.6373,14.3259C12.5748,14.4122 12.484,14.4554 12.3649,14.4554C12.2518,14.4554 12.1328,14.4197 12.0078,14.3482L7.99888,12.2411L3.98995,14.3482C3.85901,14.4197 3.73996,14.4554 3.63281,14.4554C3.50781,14.4554 3.41406,14.4122 3.35156,14.3259C3.28906,14.2396 3.25781,14.1339 3.25781,14.0089C3.25781,13.9732 3.26377,13.9137 3.27567,13.8304L4.04353,9.36608L0.793531,6.20536C0.644719,6.04464 0.570313,5.90178 0.570313,5.77678C0.570313,5.55654 0.736979,5.41964 1.07031,5.36606L5.55245,4.71428L7.56138,0.651781C7.67447,0.407729 7.8203,0.285703 7.99888,0.285703C8.17745,0.285703 8.32328,0.407729 8.43638,0.651781L10.4453,4.71428L14.9274,5.36606C15.2608,5.41964 15.4274,5.55654 15.4274,5.77678L15.4275,5.77678Z" />
</svg>
);
}

render() {
const className = classNames(
'icon-star',
{
'icon-star-favorite': this.state.favorite
},
this.props.className
);

return (
<a className={className} href="#" onClick={this.toggleFavorite}>
{this.renderSVG()}
<a
className={classNames('link-no-underline', this.props.className)}
href="#"
onClick={this.toggleFavorite}>
<FavoriteIcon favorite={this.state.favorite} />
</a>
);
}

+ 6
- 19
server/sonar-web/src/main/js/components/controls/FavoriteBaseStateless.js View File

@@ -19,6 +19,7 @@
*/
import React from 'react';
import classNames from 'classnames';
import FavoriteIcon from '../common/FavoriteIcon';

export default class FavoriteBaseStateless extends React.PureComponent {
static propTypes = {
@@ -37,27 +38,13 @@ export default class FavoriteBaseStateless extends React.PureComponent {
}
};

renderSVG() {
/* eslint max-len: 0 */
return (
<svg width="16" height="16">
<path d="M15.4275,5.77678C15.4275,5.90773 15.3501,6.05059 15.1953,6.20536L11.9542,9.36608L12.7221,13.8304C12.728,13.872 12.731,13.9316 12.731,14.0089C12.731,14.1339 12.6998,14.2396 12.6373,14.3259C12.5748,14.4122 12.484,14.4554 12.3649,14.4554C12.2518,14.4554 12.1328,14.4197 12.0078,14.3482L7.99888,12.2411L3.98995,14.3482C3.85901,14.4197 3.73996,14.4554 3.63281,14.4554C3.50781,14.4554 3.41406,14.4122 3.35156,14.3259C3.28906,14.2396 3.25781,14.1339 3.25781,14.0089C3.25781,13.9732 3.26377,13.9137 3.27567,13.8304L4.04353,9.36608L0.793531,6.20536C0.644719,6.04464 0.570313,5.90178 0.570313,5.77678C0.570313,5.55654 0.736979,5.41964 1.07031,5.36606L5.55245,4.71428L7.56138,0.651781C7.67447,0.407729 7.8203,0.285703 7.99888,0.285703C8.17745,0.285703 8.32328,0.407729 8.43638,0.651781L10.4453,4.71428L14.9274,5.36606C15.2608,5.41964 15.4274,5.55654 15.4274,5.77678L15.4275,5.77678Z" />
</svg>
);
}

render() {
const className = classNames(
'icon-star',
{
'icon-star-favorite': this.props.favorite
},
this.props.className
);

return (
<a className={className} href="#" onClick={this.toggleFavorite}>
{this.renderSVG()}
<a
className={classNames('link-no-underline', this.props.className)}
href="#"
onClick={this.toggleFavorite}>
<FavoriteIcon favorite={this.props.favorite} />
</a>
);
}

+ 2
- 2
server/sonar-web/src/main/js/components/controls/__tests__/FavoriteBase-test.js View File

@@ -30,12 +30,12 @@ function renderFavoriteBase(props) {

it('should render favorite', () => {
const favorite = renderFavoriteBase({ favorite: true });
expect(favorite.is('.icon-star-favorite')).toBe(true);
expect(favorite).toMatchSnapshot();
});

it('should render not favorite', () => {
const favorite = renderFavoriteBase({ favorite: false });
expect(favorite.is('.icon-star-favorite')).toBe(false);
expect(favorite).toMatchSnapshot();
});

it('should add favorite', () => {

+ 27
- 0
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/FavoriteBase-test.js.snap View File

@@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render favorite 1`] = `
<a
className="link-no-underline"
href="#"
onClick={[Function]}
>
<FavoriteIcon
favorite={true}
size={16}
/>
</a>
`;

exports[`should render not favorite 1`] = `
<a
className="link-no-underline"
href="#"
onClick={[Function]}
>
<FavoriteIcon
favorite={false}
size={16}
/>
</a>
`;

+ 6
- 1
server/sonar-web/src/main/js/components/issue/components/IssueAssign.js View File

@@ -47,7 +47,12 @@ export default class IssueAssign extends React.PureComponent {
<span>
{issue.assignee &&
<span className="text-top">
<Avatar className="little-spacer-right" hash={issue.assigneeAvatar} size={16} />
<Avatar
className="little-spacer-right"
hash={issue.assigneeAvatar}
name={issue.assigneeName}
size={16}
/>
</span>}
<span className="issue-meta-label">
{issue.assignee ? issue.assigneeName : translate('unassigned')}

+ 6
- 1
server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.js View File

@@ -72,7 +72,12 @@ export default class IssueCommentLine extends React.PureComponent {
return (
<div className="issue-comment">
<div className="issue-comment-author" title={comment.authorName}>
<Avatar className="little-spacer-right" hash={comment.authorAvatar} size={16} />
<Avatar
className="little-spacer-right"
hash={comment.authorAvatar}
name={comment.authorName}
size={16}
/>
{comment.authorName}
</div>
<div

+ 3
- 0
server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.js.snap View File

@@ -48,6 +48,7 @@ exports[`should open the popup when the button is clicked 2`] = `
<Connect(Avatar)
className="little-spacer-right"
hash="gravatarhash"
name="John Doe"
size={16}
/>
</span>
@@ -94,6 +95,7 @@ exports[`should render with the action 1`] = `
<Connect(Avatar)
className="little-spacer-right"
hash="gravatarhash"
name="John Doe"
size={16}
/>
</span>
@@ -118,6 +120,7 @@ exports[`should render without the action when the correct rights are missing 1`
<Connect(Avatar)
className="little-spacer-right"
hash="gravatarhash"
name="John Doe"
size={16}
/>
</span>

+ 3
- 0
server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.js.snap View File

@@ -23,6 +23,7 @@ exports[`should open the right popups when the buttons are clicked 3`] = `
<Connect(Avatar)
className="little-spacer-right"
hash="gravatarhash"
name="John Doe"
size={16}
/>
John Doe
@@ -117,6 +118,7 @@ exports[`should render correctly a comment that is not updatable 1`] = `
<Connect(Avatar)
className="little-spacer-right"
hash="gravatarhash"
name="John Doe"
size={16}
/>
John Doe
@@ -153,6 +155,7 @@ exports[`should render correctly a comment that is updatable 1`] = `
<Connect(Avatar)
className="little-spacer-right"
hash="gravatarhash"
name="John Doe"
size={16}
/>
John Doe

+ 6
- 3
server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.js View File

@@ -94,9 +94,12 @@ export default class ChangelogPopup extends React.PureComponent {
{moment(item.creationDate).format('LLL')}
</td>
<td className="thin text-left text-top nowrap">
{item.userName &&
item.avatar &&
<Avatar className="little-spacer-right" hash={item.avatar} size={16} />}
<Avatar
className="little-spacer-right"
hash={item.avatar}
name={item.userName}
size={16}
/>
{item.userName}
</td>
<td className="text-left text-top">

+ 7
- 7
server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js View File

@@ -141,13 +141,13 @@ export default class SetAssigneePopup extends React.PureComponent {
onSelect={this.props.onSelect}>
{this.state.users.map(user => (
<SelectListItem key={user.login} item={user.login}>
{(user.avatar || user.email) &&
<Avatar
className="spacer-right"
email={user.email}
hash={user.avatar}
size={16}
/>}
<Avatar
className="spacer-right"
email={user.email}
hash={user.avatar}
name={user.name}
size={16}
/>
<span
className="vertical-middle"
style={{ marginLeft: !user.avatar && !user.email ? 24 : undefined }}>

+ 1
- 0
server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js View File

@@ -96,6 +96,7 @@ export default class SimilarIssuesPopup extends React.PureComponent {
<Avatar
className="little-spacer-left little-spacer-right"
hash={issue.assigneeAvatar}
name={issue.assigneeName}
size={16}
/>
{issue.assigneeName}

+ 1
- 0
server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.js.snap View File

@@ -38,6 +38,7 @@ exports[`should render the changelog popup correctly 1`] = `
<Connect(Avatar)
className="little-spacer-right"
hash="gravatarhash"
name="john.doe"
size={16}
/>
john.doe

+ 56
- 1
server/sonar-web/src/main/js/components/ui/Avatar.js View File

@@ -23,19 +23,74 @@ import md5 from 'blueimp-md5';
import classNames from 'classnames';
import { getSettingValue } from '../../store/rootReducer';

function stringToColor(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let color = '#';
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += ('00' + value.toString(16)).substr(-2);
}
return color;
}

function getTextColor(background) {
const rgb = parseInt(background.substr(1), 16);
const r = (rgb >> 16) & 0xff;
const g = (rgb >> 8) & 0xff;
const b = (rgb >> 0) & 0xff;
const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luma > 140 ? '#222' : '#fff';
}

class Avatar extends React.PureComponent {
static propTypes = {
enableGravatar: React.PropTypes.bool.isRequired,
gravatarServerUrl: React.PropTypes.string.isRequired,
email: React.PropTypes.string,
hash: React.PropTypes.string,
name: React.PropTypes.string.isRequired,
size: React.PropTypes.number.isRequired,
className: React.PropTypes.string
};

renderFallback() {
const className = classNames(this.props.className, 'rounded');
const color = stringToColor(this.props.name);

let text = '';
const words = this.props.name.split(/\s+/).filter(word => word.length > 0);
if (words.length >= 2) {
text = words[0][0] + words[1][0];
} else if (this.props.name.length > 0) {
text = this.props.name[0];
}

return (
<div
className={className}
style={{
backgroundColor: color,
color: getTextColor(color),
display: 'inline-block',
fontSize: Math.min(this.props.size / 2, 14),
fontWeight: 'normal',
height: this.props.size,
lineHeight: `${this.props.size}px`,
textAlign: 'center',
verticalAlign: 'top',
width: this.props.size
}}>
{text.toUpperCase()}
</div>
);
}

render() {
if (!this.props.enableGravatar) {
return null;
return this.renderFallback();
}

const emailHash = this.props.hash || md5.md5((this.props.email || '').toLowerCase()).trim();

+ 11
- 22
server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.js View File

@@ -29,26 +29,11 @@ it('should render', () => {
enableGravatar={true}
gravatarServerUrl={gravatarServerUrl}
email="mail@example.com"
name="Foo"
size={20}
/>
);
expect(avatar.is('img')).toBe(true);
expect(avatar.prop('width')).toBe(20);
expect(avatar.prop('height')).toBe(20);
expect(avatar.prop('alt')).toBe('mail@example.com');
expect(avatar.prop('src')).toBe('http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=40');
});

it('should not render', () => {
const avatar = shallow(
<Avatar
enableGravatar={false}
gravatarServerUrl={gravatarServerUrl}
email="mail@example.com"
size={20}
/>
);
expect(avatar.is('img')).toBe(false);
expect(avatar).toMatchSnapshot();
});

it('should be able to render with hash only', () => {
@@ -57,12 +42,16 @@ it('should be able to render with hash only', () => {
enableGravatar={true}
gravatarServerUrl={gravatarServerUrl}
hash="7daf6c79d4802916d83f6266e24850af"
name="Foo"
size={30}
/>
);
expect(avatar.is('img')).toBe(true);
expect(avatar.prop('width')).toBe(30);
expect(avatar.prop('height')).toBe(30);
expect(avatar.prop('alt')).toBeUndefined();
expect(avatar.prop('src')).toBe('http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=60');
expect(avatar).toMatchSnapshot();
});

it('falls back to dummy avatar', () => {
const avatar = shallow(
<Avatar enableGravatar={false} gravatarServerUrl="" name="Foo Bar" size={30} />
);
expect(avatar).toMatchSnapshot();
});

+ 42
- 0
server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.js.snap View File

@@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`falls back to dummy avatar 1`] = `
<div
className="rounded"
style={
Object {
"backgroundColor": "#79e189",
"color": "#222",
"display": "inline-block",
"fontSize": 14,
"fontWeight": "normal",
"height": 30,
"lineHeight": "30px",
"textAlign": "center",
"verticalAlign": "top",
"width": 30,
}
}
>
FB
</div>
`;

exports[`should be able to render with hash only 1`] = `
<img
className="rounded"
height={30}
src="http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=60"
width={30}
/>
`;

exports[`should render 1`] = `
<img
alt="mail@example.com"
className="rounded"
height={20}
src="http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=40"
width={20}
/>
`;

+ 10
- 14
server/sonar-web/src/main/js/helpers/handlebars/avatarHelper.js View File

@@ -17,22 +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 md5 from 'blueimp-md5';
import React from 'react';
import { renderToString } from 'react-dom/server';
import Handlebars from 'handlebars/runtime';
import WithStore from '../../components/shared/WithStore';
import Avatar from '../../components/ui/Avatar';

function gravatarServer() {
const getStore = require('../../app/utils/getStore').default;
const { getSettingValue } = require('../../store/rootReducer');

const store = getStore();
return (getSettingValue(store.getState(), 'sonar.lf.gravatarServerUrl') || {}).value;
}

module.exports = function(email, size) {
// double the size for high pixel density screens
const emailHash = md5.md5((email || '').trim());
const url = gravatarServer().replace('{EMAIL_MD5}', emailHash).replace('{SIZE}', size * 2);
module.exports = function(email, name, size) {
return new Handlebars.default.SafeString(
`<img class="rounded" src="${url}" width="${size}" height="${size}" alt="${email}">`
renderToString(
<WithStore>
<Avatar email={email} name={name} size={size} />
</WithStore>
)
);
};

+ 13
- 0
server/sonar-web/src/main/js/helpers/testUtils.js View File

@@ -26,6 +26,11 @@ export const mockEvent = {

export const click = (element, event = {}) => element.simulate('click', { ...mockEvent, ...event });

export const clickOutside = (event = {}) => {
const dispatchedEvent = new MouseEvent('click', event);
window.dispatchEvent(dispatchedEvent);
};

export const submit = element =>
element.simulate('submit', {
preventDefault() {}
@@ -41,3 +46,11 @@ export const keydown = keyCode => {
const event = new KeyboardEvent('keydown', { keyCode });
document.dispatchEvent(event);
};

export const elementKeydown = (element, keyCode) => {
element.simulate('keydown', {
currentTarget: { element },
keyCode,
preventDefault() {}
});
};

+ 28
- 0
server/sonar-web/src/main/less/components/menu.less View File

@@ -76,9 +76,27 @@
background-color: @barBackgroundColor;
}
}

.menu-footer {
display: block;
padding: 8px 16px 4px;
white-space: nowrap;

& > a {
display: inline;
padding: 0;
border-bottom: 1px solid @darkGrey;
color: @secondFontColor;

&:hover {
background: none;
}
}
}
}

.menu-search {
position: relative;
padding: 4px 16px 0;

.search-box-input { font-size: @smallFontSize; }
@@ -86,6 +104,16 @@
.search-box-submit { vertical-align: baseline; }
}

.menu-search-full-width {
display: flex;
align-items: center;

.search-box-input {
flex-grow: 1;
width: auto;
}
}

.menu-search ~ .menu {
> li > a {
&:hover, &:focus {

+ 68
- 17
server/sonar-web/src/main/less/components/navbar.less View File

@@ -93,6 +93,16 @@
.navbar-nav > li > a {
padding: @navbarTopPadding 10px;
line-height: @navbarLineHeight;

&.navbar-avatar {
margin-right: 7px;
padding: 3px;
}

&.navbar-help {
line-height: 16px;
padding: 7px;
}
}

.navbar-nav > li.navbar-more > a {
@@ -124,21 +134,34 @@

.navbar-search {
position: relative;
width: 480px;
box-sizing: border-box;
transition: width 0.2s ease;
padding-right: 3px;
}

.navbar-search-input {
width: 100%;
padding: 0 20px 0 40px !important;
border: none !important;
width: 280px;
margin-top: 3px;
margin-bottom: 3px;
padding-left: 26px !important;
}

.navbar-search-icon {
.navbar-search-input-hint {
position: absolute;
top: 4px;
left: 20px;
right: 30px;
line-height: @formControlHeight;
font-size: 12px;
color: @secondFontColor;

&.is-shifted {
z-index: @dropdown-menu-z-index + 1;
top: 32px;
}
}

.navbar-search-icon {
position: relative;
width: 16px;
margin-right: -20px;
color: @secondFontColor;

&:before {
@@ -146,19 +169,44 @@
}
}

.navbar-search-extra {
.navbar-search-item-icons {
position: relative;
display: inline-block;
width: 90px;
margin-right: 10px;
text-align: right;
}
vertical-align: middle;
width: 16px;
height: 16px;

> * {
position: absolute;
z-index: 5;
top: 0;
left: 0;
}

.navbar-search-subtitle {
position: absolute;
top: 5px;
right: 40px;
> .icon-star,
> .icon-clock {
z-index: 6;
top: -5px;
left: -5px;
}
}

.navbar-search-shortcut-hint {
margin-top: 5px;
padding: 5px 10px;
border-top: 1px solid #e6e6e6;
background-color: #f3f3f3;
color: #777;
font-size: 11px;

.shortcut-button {
min-width: 16px;
height: 16px;
line-height: 12px;
margin-left: 4px;
margin-right: 4px;
}
}

.navbar-global {
top: 0;
@@ -293,6 +341,9 @@

.global-navbar-search-dropdown {
max-height: 80vh;
width: 440px;
padding: 0;
overflow-y: auto;
overflow-x: hidden;
box-shadow: @defaultShadow;
}

+ 1
- 0
server/sonar-web/src/main/less/components/tooltips.less View File

@@ -78,6 +78,7 @@
border-radius: 4px;
letter-spacing: 0.04em;
overflow: hidden;
word-break: break-word;

.alert {
margin-bottom: 5px /* align with side padding */ ;

+ 3
- 3
server/sonar-web/src/main/less/init/icons.less View File

@@ -178,14 +178,14 @@ a[class^="icon-"], a[class*=" icon-"] {
.icon-qualifier-dir,
.icon-qualifier-pac { background-image: url('data:image/svg+xml,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M14%2012.286V5.703c0-.198-.058-.36-.195-.5S13.512%205%2013.315%205H6.704c-.196%200-.36-.075-.5-.214-.136-.14-.203-.312-.203-.51v-.57c0-.2-.07-.363-.207-.502C5.655%203.064%205.487%203%205.29%203H2.707c-.196%200-.363.065-.5.204-.137.14-.206.302-.206.5v8.582c0%20.2.07.367.206.506.137.14.304.208.5.208h10.61c.196%200%20.352-.07.49-.208.137-.14.194-.307.194-.506zm1-6.598v6.65c0%20.458-.152.83-.475%201.16-.324.326-.7.502-1.15.502H2.647c-.452%200-.84-.175-1.162-.503-.324-.328-.486-.7-.486-1.158V3.654c0-.457.162-.842.486-1.17C1.81%202.158%202.196%202%202.648%202h2.7c.45%200%20.84.157%201.164.485.324.328.488.714.488%201.17V4h6.373c.452%200%20.83.174%201.152.5.323.33.475.73.475%201.187z%22%20fill%3D%22%23F90%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E'); }
.icon-qualifier-trk,
.icon-qualifier-brc,
.icon-qualifier-dev_prj { background-image: url('data:image/svg+xml,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M14.985%2013.988L1%2014.005%201.02%205h13.966v8.988zM1.998%205.995l.006%207.02L14.022%2013%2014%206.004l-12.002-.01zM3%204.5V4h9.996l.004.5h1l-.005-1.497-11.98.003L2%204.5zm1-2v-.504h8.002L12%202.5h1l-.004-1.495H3.003L3%202.5z%22%20fill%3D%22%232D88C0%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E'); }
.icon-qualifier-brc { background-image: url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%3E%3Cpath%20d%3D%22M16%2016H6v-6h10v6zm-9-1h8v-4H7v4zM7%209h8v1H7zM8%208h6v1H8z%22%20fill%3D%22%232D88C0%22%2F%3E%3Cpath%20d%3D%22M5%2012H1V5h12v2h1V4H0v9h5zM3%201h8v.5h1V0H2v1.5h1zM2%203h10v.5h1V2H1v1.5h1z%22%20fill%3D%22%232D88C0%22%2F%3E%3C%2Fsvg%3E'); }
.icon-qualifier-cla,
.icon-qualifier-uts { background-image: url('data:image/svg+xml,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M3%2014h10V6H9V2H3zm7.012-9h3.008c-.012-.674-.78-1.258-1.27-1.752-.488-.495-.973-1.243-1.75-1.24v2.96zM14%204.995V15H2V1l7.997.02c1.013-.03%201.57.893%202.239%201.555.667.663%201.75%201.47%201.763%202.42z%22%20fill%3D%22%232D88C0%22%20fill-rule%3D%22nonzero%22%2F%3E%3Cpath%20d%3D%22M7%208l-3%202.5L7%2013zM8%2013l3-2.5L8%208z%22%20fill%3D%22%232D88C0%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E'); }
.icon-qualifier-fil { background-image: url('data:image/svg+xml,%3Csvg%20width%3D%2215%22%20height%3D%2216%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M3%2014h10V6H9V2H3zm7.012-9h3.008c-.012-.674-.78-1.258-1.27-1.752-.488-.495-.973-1.243-1.75-1.24v2.96zM14%204.995V15H2V1l7.997.02c1.013-.03%201.57.893%202.239%201.555.667.663%201.75%201.47%201.763%202.42z%22%20fill%3D%22%232D88C0%22%20fill-rule%3D%22nonzero%22%2F%3E%3Cpath%20fill%3D%22%232D88C0%22%20d%3D%22M4%2011h8v1H4zM4%209h8v1H4z%22%2F%3E%3C%2Fsvg%3E'); }
.icon-qualifier-lib { background-image: url('data:image/svg+xml,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M1%2013h4V3H1zm3-1H2v-2h2v2zM2%204h2v4H2zM6%2013h4V3H6zm3-1H7v-2h2v2zM7%204h2v4H7zM11%2013h4V3h-4zm3-1h-2v-2h2v2zm-2-8h2v4h-2z%22%20fill%3D%22%232D88C0%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E'); }
.icon-qualifier-vw,
.icon-qualifier-svw { background-image: url('data:image/svg+xml,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M1.016%2014.97V1.015H14.97V14.97H1.015zm1-1H13.97V2.015H2.015V13.97z%22%20fill%3D%22%232D88C0%22%2F%3E%3Cpath%20d%3D%22M3.006%207V3.006H7V7H3.006zm1-1H6V4.006H4.006V6zM9%207V3.015h3.985V7H9zm1-1h1.985V4.015H10V6zM3.004%2012.996V9H7v3.996H3.004zm1-1H6V10H4.004v1.996zM9%2012.997V9h3.997v3.997H9zm1-1h1.997V10H10v1.997z%22%20fill%3D%22%232D88C0%22%2F%3E%3C%2Fsvg%3E'); }
.icon-qualifier-vw { background-image: url('data:image/svg+xml,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M1.016%2014.97V1.015H14.97V14.97H1.015zm1-1H13.97V2.015H2.015V13.97z%22%20fill%3D%22%232D88C0%22%2F%3E%3Cpath%20d%3D%22M3.006%207V3.006H7V7H3.006zm1-1H6V4.006H4.006V6zM9%207V3.015h3.985V7H9zm1-1h1.985V4.015H10V6zM3.004%2012.996V9H7v3.996H3.004zm1-1H6V10H4.004v1.996zM9%2012.997V9h3.997v3.997H9zm1-1h1.997V10H10v1.997z%22%20fill%3D%22%232D88C0%22%2F%3E%3C%2Fsvg%3E'); }
.icon-qualifier-svw { background-image: url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%3E%3Cpath%20d%3D%22M13%207.2V1H1v12h7v1H0V0h14v7.2%22%20fill%3D%22%232D88C0%22%2F%3E%3Cpath%20d%3D%22M2%206V2h4v4H2zm1-1h2V3H3v2zm5%201V2h4v4H8zm1-1h2V3H9v2zm-7%207V8h4v4H2zm1-1h2V9H3v2zM16%2016H7V7h9v9zm-8-1h7V8H8v7z%22%20fill%3D%22%232D88C0%22%2F%3E%3Cpath%20d%3D%22M9%209h2v2H9zM12%209h2v2h-2zM9%2012h2v2H9zM12%2012h2v2h-2z%22%20fill%3D%22%232D88C0%22%2F%3E%3C%2Fsvg%3E'); }
.icon-qualifier-dev { background-image: url('data:image/svg+xml,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M7.974%208.02c-.938%200-1.82-.36-2.482-1.017-.663-.655-1.028-1.527-1.028-2.455%200-.927.365-1.8%201.028-2.455.663-.656%201.544-1.017%202.482-1.017.937%200%201.82.36%202.482%201.017.662.656%201.027%201.528%201.027%202.455%200%20.928-.365%201.8-1.027%202.455C9.793%207.66%208.91%208.02%207.974%208.02zm0-5.778c-1.286%200-2.332%201.034-2.332%202.306s1.046%202.307%202.332%202.307c1.285%200%202.332-1.035%202.332-2.307S9.258%202.242%207.974%202.242zm3.534%206.418c.127.016.243.045.348.086.17.066.302.146.406.246.132.124.253.282.36.47.126.218.226.442.3.668.08.253.15.535.206.838.056.313.095.604.113.867.02.28.03.57.03.862%200%20.532-.174.758-.306.882-.142.132-.397.31-.973.31H3.948c-.233%200-.437-.03-.606-.09-.14-.05-.26-.123-.366-.222-.13-.123-.306-.35-.306-.88%200-.294.01-.584.03-.863.018-.263.056-.554.112-.867.055-.303.125-.585.207-.838.073-.226.173-.45.298-.667.108-.19.23-.347.36-.47.106-.1.238-.18.407-.247.105-.04.22-.07.348-.086.202.13.432.277.683.435.342.217.756.4%201.265.564.523.166%201.06.25%201.59.25.534%200%201.07-.084%201.592-.25.51-.164.923-.348%201.266-.565.25-.158.48-.304.682-.435zm-.244-1.18c-.055%200-.184.066-.387.196-.202.13-.43.276-.685.437-.255.16-.586.307-.994.437-.408.13-.818.196-1.23.196-.41%200-.82-.065-1.228-.196-.408-.13-.74-.276-.993-.437-.255-.16-.484-.306-.686-.437-.202-.13-.33-.196-.386-.196-.374%200-.716.06-1.026.183-.31.12-.572.283-.787.487-.213.203-.404.45-.57.737-.165.288-.297.584-.395.888-.098.303-.18.633-.244.988-.063.355-.106.685-.128.992-.02.306-.032.62-.032.942%200%20.73.224%201.304.672%201.726.448.42%201.043.632%201.785.632h8.044c.743%200%201.34-.21%201.787-.633.447-.42.67-.996.67-1.725%200-.32-.01-.635-.03-.942-.022-.307-.065-.637-.13-.992-.064-.355-.146-.685-.244-.988-.098-.304-.23-.6-.395-.888-.166-.288-.356-.534-.57-.737-.216-.204-.478-.366-.788-.487-.31-.122-.652-.183-1.026-.183z%22%20fill%3D%22%232D88C0%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E'); }



+ 6
- 0
server/sonar-web/src/main/less/init/type.less View File

@@ -68,6 +68,11 @@ sub { vertical-align: text-bottom; }
em { font-style: italic; }
strong { font-weight: 600; }

mark {
background: none;
font-weight: bold;
}

.emphasised-measure {
font-size: 24px;
font-weight: 300;
@@ -139,6 +144,7 @@ small,

.text-emphasis-variant(@color) { color: @color; }
.text-muted { .text-emphasis-variant(@secondFontColor); }
.text-muted-2 { .text-emphasis-variant(@middleGrey); }
.text-danger { .text-emphasis-variant(@red); }
.text-warning { .text-emphasis-variant(@orange); }
.text-info { .text-emphasis-variant(@blue); }

+ 3
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -588,6 +588,7 @@ portfolios.page=Portfolios
project_activity.page=Activity
project_activity.page.description=The page shows the history of project analyses.


#------------------------------------------------------------------------------
#
# ASYNC PROCESS
@@ -1013,7 +1014,8 @@ property.category.scm=SCM
#------------------------------------------------------------------------------
search.results=results
search.duration=({0} seconds)
search.shortcut=Press S to quickly open search bar
search.shortcut_hint=Hint: Press <span class="shortcut-button">{0}</span> from anywhere to open this search bar.
search.placeholder=Search for projects, modules and files...


#------------------------------------------------------------------------------

Loading…
Cancel
Save