From 72e45fffdea16673f257cb80b40269e73ccffaba Mon Sep 17 00:00:00 2001 From: Stas Vilchik Date: Tue, 9 May 2017 17:53:29 +0200 Subject: [PATCH] MMF-661 rework search (#2030) --- .../it/user/BaseIdentityProviderTest.java | 3 +- .../java/it/user/LocalAuthenticationTest.java | 9 +- .../test/java/it/user/MyAccountPageTest.java | 11 +- .../it/user/OAuth2IdentityProviderTest.java | 3 +- .../src/test/java/pageobjects/LoginPage.java | 5 + .../authenticate_user.html | 29 -- .../force-authentication.html | 5 - .../login_successful.html | 7 +- ...ct_to_original_url_after_direct_login.html | 5 - ..._to_original_url_after_indirect_login.html | 5 - ...rl_with_parameters_after_direct_login.html | 5 - ..._be_unlogged_when_going_to_login_page.html | 4 +- .../should_change_password.html | 110 ----- .../authenticate_user.html | 29 -- .../sonar-web/flow-typed/npm/lodash_v4.x.x.js | 2 +- .../sonar-web/src/main/js/api/components.js | 41 +- .../{nav/component => }/RecentHistory.js | 13 +- .../components/nav/component/ComponentNav.js | 2 +- .../js/app/components/nav/global/GlobalNav.js | 18 +- .../components/nav/global/GlobalNavSearch.js | 116 ----- .../nav/global/GlobalNavSearchForm.js | 419 ++++++++++++++++++ .../global/GlobalNavSearchFormComponent.js | 113 +++++ .../components/nav/global/GlobalNavUser.js | 5 +- .../app/components/nav/global/SearchView.js | 303 ------------- .../__tests__/GlobalNavSearchForm-test.js | 140 ++++++ .../GlobalNavSearchFormComponent-test.js | 109 +++++ .../GlobalNavSearchForm-test.js.snap | 262 +++++++++++ .../GlobalNavSearchFormComponent-test.js.snap | 323 ++++++++++++++ .../nav/templates/nav-search-empty.hbs | 1 - .../nav/templates/nav-search-item.hbs | 28 -- .../components/nav/templates/nav-search.hbs | 8 - .../src/main/js/app/styles/boxed-group.css | 7 +- .../js/apps/account/components/UserCard.js | 2 +- .../apps/issues/components/BulkChangeModal.js | 1 + .../js/apps/issues/sidebar/AssigneeFacet.js | 8 +- .../__snapshots__/AssigneeFacet-test.js.snap | 4 + .../components/MembersListItem.js | 2 +- .../MembersListItem-test.js.snap | 3 + .../shared/components/UserHolder.js | 7 +- .../js/apps/project-admin/key/BulkUpdate.js | 2 +- .../src/main/js/apps/project-admin/key/Key.js | 2 +- .../js/apps/sessions/components/Logout.js | 2 +- .../components/UsersSelectSearchOption.js | 2 +- .../components/UsersSelectSearchValue.js | 2 +- .../UsersSelectSearchOption-test.js.snap | 2 + .../UsersSelectSearchValue-test.js.snap | 2 + .../apps/users/templates/users-list-item.hbs | 8 +- .../common/ClockIcon.js} | 35 +- .../js/components/common/DeferredSpinner.js | 83 ++++ .../main/js/components/common/FavoriteIcon.js | 44 ++ .../common/__tests__/DeferredSpinner-test.js | 56 +++ .../DeferredSpinner-test.js.snap | 92 ++++ .../js/components/controls/FavoriteBase.js | 25 +- .../controls/FavoriteBaseStateless.js | 25 +- .../controls/__tests__/FavoriteBase-test.js | 4 +- .../__snapshots__/FavoriteBase-test.js.snap | 27 ++ .../issue/components/IssueAssign.js | 7 +- .../issue/components/IssueCommentLine.js | 7 +- .../__snapshots__/IssueAssign-test.js.snap | 3 + .../IssueCommentLine-test.js.snap | 3 + .../components/issue/popups/ChangelogPopup.js | 9 +- .../issue/popups/SetAssigneePopup.js | 14 +- .../issue/popups/SimilarIssuesPopup.js | 1 + .../__snapshots__/ChangelogPopup-test.js.snap | 1 + .../src/main/js/components/ui/Avatar.js | 57 ++- .../js/components/ui/__tests__/Avatar-test.js | 33 +- .../__snapshots__/Avatar-test.js.snap | 42 ++ .../js/helpers/handlebars/avatarHelper.js | 24 +- .../src/main/js/helpers/testUtils.js | 13 + .../src/main/less/components/menu.less | 28 ++ .../src/main/less/components/navbar.less | 85 +++- .../src/main/less/components/tooltips.less | 1 + .../sonar-web/src/main/less/init/icons.less | 6 +- server/sonar-web/src/main/less/init/type.less | 6 + .../resources/org/sonar/l10n/core.properties | 4 +- 75 files changed, 2105 insertions(+), 819 deletions(-) delete mode 100644 it/it-tests/src/test/resources/user/BaseIdentityProviderTest/authenticate_user.html delete mode 100644 it/it-tests/src/test/resources/user/MyAccountPageTest/should_change_password.html delete mode 100644 it/it-tests/src/test/resources/user/OAuth2IdentityProviderTest/authenticate_user.html rename server/sonar-web/src/main/js/app/components/{nav/component => }/RecentHistory.js (86%) delete mode 100644 server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js create mode 100644 server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js create mode 100644 server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js delete mode 100644 server/sonar-web/src/main/js/app/components/nav/global/SearchView.js create mode 100644 server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js create mode 100644 server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js create mode 100644 server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap create mode 100644 server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap delete mode 100644 server/sonar-web/src/main/js/app/components/nav/templates/nav-search-empty.hbs delete mode 100644 server/sonar-web/src/main/js/app/components/nav/templates/nav-search-item.hbs delete mode 100644 server/sonar-web/src/main/js/app/components/nav/templates/nav-search.hbs rename server/sonar-web/src/main/js/{helpers/handlebars/avatarHelperNew.js => components/common/ClockIcon.js} (57%) create mode 100644 server/sonar-web/src/main/js/components/common/DeferredSpinner.js create mode 100644 server/sonar-web/src/main/js/components/common/FavoriteIcon.js create mode 100644 server/sonar-web/src/main/js/components/common/__tests__/DeferredSpinner-test.js create mode 100644 server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DeferredSpinner-test.js.snap create mode 100644 server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/FavoriteBase-test.js.snap create mode 100644 server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.js.snap diff --git a/it/it-tests/src/test/java/it/user/BaseIdentityProviderTest.java b/it/it-tests/src/test/java/it/user/BaseIdentityProviderTest.java index b173f0b023c..36dc82ec7a9 100644 --- a/it/it-tests/src/test/java/it/user/BaseIdentityProviderTest.java +++ b/it/it-tests/src/test/java/it/user/BaseIdentityProviderTest.java @@ -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); } diff --git a/it/it-tests/src/test/java/it/user/LocalAuthenticationTest.java b/it/it-tests/src/test/java/it/user/LocalAuthenticationTest.java index feb2c6c4550..b0825c67179 100644 --- a/it/it-tests/src/test/java/it/user/LocalAuthenticationTest.java +++ b/it/it-tests/src/test/java/it/user/LocalAuthenticationTest.java @@ -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 diff --git a/it/it-tests/src/test/java/it/user/MyAccountPageTest.java b/it/it-tests/src/test/java/it/user/MyAccountPageTest.java index 5faccd0e466..2539c9f0551 100644 --- a/it/it-tests/src/test/java/it/user/MyAccountPageTest.java +++ b/it/it-tests/src/test/java/it/user/MyAccountPageTest.java @@ -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 diff --git a/it/it-tests/src/test/java/it/user/OAuth2IdentityProviderTest.java b/it/it-tests/src/test/java/it/user/OAuth2IdentityProviderTest.java index 303e78b4474..fa91d6cfefb 100644 --- a/it/it-tests/src/test/java/it/user/OAuth2IdentityProviderTest.java +++ b/it/it-tests/src/test/java/it/user/OAuth2IdentityProviderTest.java @@ -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); } diff --git a/it/it-tests/src/test/java/pageobjects/LoginPage.java b/it/it-tests/src/test/java/pageobjects/LoginPage.java index 0168350a7c0..f9afa6a289b 100644 --- a/it/it-tests/src/test/java/pageobjects/LoginPage.java +++ b/it/it-tests/src/test/java/pageobjects/LoginPage.java @@ -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); diff --git a/it/it-tests/src/test/resources/user/BaseIdentityProviderTest/authenticate_user.html b/it/it-tests/src/test/resources/user/BaseIdentityProviderTest/authenticate_user.html deleted file mode 100644 index adb819dd425..00000000000 --- a/it/it-tests/src/test/resources/user/BaseIdentityProviderTest/authenticate_user.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - -
open/sessions/new
waitForTextcontent*Log in with Fake base identity provider*
clickcss=.oauth-providers a
waitForTextid=global-navigation*John*
- - diff --git a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/force-authentication.html b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/force-authentication.html index 1b542e5360c..47468b46c6b 100644 --- a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/force-authentication.html +++ b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/force-authentication.html @@ -48,11 +48,6 @@ css=.js-user-authenticated - - waitForText - css=.navbar - *Administrator* - open /sessions/logout diff --git a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/login_successful.html b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/login_successful.html index 68257d74485..3e146e2d49f 100644 --- a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/login_successful.html +++ b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/login_successful.html @@ -43,14 +43,9 @@ css=.js-user-authenticated - - waitForText - css=.navbar - *Administrator* - click - Link=Administrator + css=.js-user-authenticated diff --git a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_direct_login.html b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_direct_login.html index a54eb0b3fd6..a28731b9516 100644 --- a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_direct_login.html +++ b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_direct_login.html @@ -48,11 +48,6 @@ css=.js-user-authenticated - - waitForText - css=.navbar - *Administrator* - assertLocation glob:*/settings?category=general* diff --git a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_indirect_login.html b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_indirect_login.html index ca6fdd194fe..3862aa147b9 100644 --- a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_indirect_login.html +++ b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_after_indirect_login.html @@ -47,11 +47,6 @@ css=.js-user-authenticated - - waitForText - css=.navbar - *Administrator* - assertLocation */settings diff --git a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_with_parameters_after_direct_login.html b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_with_parameters_after_direct_login.html index c1c94de3ac6..c8f55d12641 100644 --- a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_with_parameters_after_direct_login.html +++ b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/redirect_to_original_url_with_parameters_after_direct_login.html @@ -57,11 +57,6 @@ css=.js-user-authenticated - - waitForText - css=.navbar - *Administrator* - assertLocation */projects?gate=OK&reliability=1&security=1 diff --git a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/should_not_be_unlogged_when_going_to_login_page.html b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/should_not_be_unlogged_when_going_to_login_page.html index 66a0ab89a51..a0f759e19cc 100644 --- a/it/it-tests/src/test/resources/user/LocalAuthenticationTest/should_not_be_unlogged_when_going_to_login_page.html +++ b/it/it-tests/src/test/resources/user/LocalAuthenticationTest/should_not_be_unlogged_when_going_to_login_page.html @@ -54,9 +54,9 @@ - waitForText + waitForElementPresent css=.js-user-authenticated - *simple-user* + diff --git a/it/it-tests/src/test/resources/user/MyAccountPageTest/should_change_password.html b/it/it-tests/src/test/resources/user/MyAccountPageTest/should_change_password.html deleted file mode 100644 index 58ad7b8b089..00000000000 --- a/it/it-tests/src/test/resources/user/MyAccountPageTest/should_change_password.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - should_change_password - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
should_change_password
open/sonar/sessions/login
typeid=loginaccount-user
typeid=passwordpassword
clickAndWaitcommit
waitForElementPresentcss=.js-user-authenticated
open/sonar/account/security
waitForElementPresentid=change-password
typeid=old_passwordpassword
typeid=passwordnew_password
typeid=password_confirmationnew_password
clickid=change-password
waitForElementPresentcss=.alert-success
open/sonar/sessions/logout
open/sonar/sessions/login
typeid=loginaccount-user
typeid=passwordnew_password
clickAndWaitcommit
waitForTextid=global-navigation*User With Account*
- - diff --git a/it/it-tests/src/test/resources/user/OAuth2IdentityProviderTest/authenticate_user.html b/it/it-tests/src/test/resources/user/OAuth2IdentityProviderTest/authenticate_user.html deleted file mode 100644 index 22b34ba03c9..00000000000 --- a/it/it-tests/src/test/resources/user/OAuth2IdentityProviderTest/authenticate_user.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - -
open/sessions/new
waitForTextcontent*Log in with Fake oauth2 identity provider*
clickcss=.oauth-providers a
waitForTextid=global-navigation*John*
- - diff --git a/server/sonar-web/flow-typed/npm/lodash_v4.x.x.js b/server/sonar-web/flow-typed/npm/lodash_v4.x.x.js index c105c64e2b8..e05380c79fd 100644 --- a/server/sonar-web/flow-typed/npm/lodash_v4.x.x.js +++ b/server/sonar-web/flow-typed/npm/lodash_v4.x.x.js @@ -193,7 +193,7 @@ declare module 'lodash' { forEach(object: T, iteratee?: OIteratee): T; forEachRight(array: ?Array, iteratee?: Iteratee): Array; forEachRight(object: T, iteratee?: OIteratee): T; - groupBy(array: ?Array, iteratee?: ValueOnlyIteratee): {[key: V]: ?Array}; + groupBy(array: ?Array, iteratee?: ValueOnlyIteratee): {[key: V]: Array}; groupBy(object: T, iteratee?: ValueOnlyIteratee): {[key: V]: ?Array}; includes(array: ?Array, value: T, fromIndex?: number): bool; includes(object: T, value: any, fromIndex?: number): bool; diff --git a/server/sonar-web/src/main/js/api/components.js b/server/sonar-web/src/main/js/api/components.js index c9bea5e039d..9552a20dfce 100644 --- a/server/sonar-web/src/main/js/api/components.js +++ b/server/sonar-web/src/main/js/api/components.js @@ -166,8 +166,45 @@ export function bulkChangeKey(project: string, from: string, to: string, dryRun? return postJSON(url, data); } -export const getSuggestions = (query: string): Promise => - 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, + more?: string +): Promise => { + 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 }); diff --git a/server/sonar-web/src/main/js/app/components/nav/component/RecentHistory.js b/server/sonar-web/src/main/js/app/components/RecentHistory.js similarity index 86% rename from server/sonar-web/src/main/js/app/components/nav/component/RecentHistory.js rename to server/sonar-web/src/main/js/app/components/RecentHistory.js index 6cd25ac4072..368eaf31748 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/RecentHistory.js +++ b/server/sonar-web/src/main/js/app/components/RecentHistory.js @@ -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( diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js index 31ed72b442e..3ee81111367 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js @@ -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'; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js index 7e62d596890..033bd787f7c 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js @@ -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 ( diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js deleted file mode 100644 index 9b40ecb2b8c..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js +++ /dev/null @@ -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 ( -
  • - -   - -
    -
  • - ); - } -} - -const mapStateToProps = state => ({ - currentUser: getCurrentUser(state) -}); - -export default connect(mapStateToProps)(GlobalNavSearch); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js new file mode 100644 index 00000000000..469a0e673fd --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js @@ -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 }, + 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 }): Array => + this.sortQualifiers(Object.keys(results)).reduce( + (components, qualifier) => [...components, ...results[qualifier]], + [] + ); + + mergeWithRecentlyBrowsed = (components: Array) => { + const recentlyBrowsed = RecentHistory.get().map(component => ({ + ...component, + isRecentlyBrowsed: true, + qualifier: component.icon.toUpperCase() + })); + return uniqBy([...components, ...recentlyBrowsed], 'key'); + }; + + fetchFavoritesAndRecentlyBrowsed = () => { + const done = (components: Array) => { + 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) => + sortBy(qualifiers, qualifier => ORDER.indexOf(qualifier)); + + innerRef = (component: string, node: HTMLElement) => { + this.nodes[component] = node; + }; + + renderComponent = (component: Component) => ( + + ); + + 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(
  • ); + } + + if (components.length > 0) { + renderedComponents.push( +
  • + {translate('qualifiers', qualifier)} +
  • + ); + } + + components.forEach(component => { + renderedComponents.push(this.renderComponent(component)); + }); + + const more = this.state.more[qualifier]; + if (more != null && more > 0) { + renderedComponents.push( +
  • + + + {translate('show_more')} + + +
  • + ); + } + }); + + return renderedComponents; + }; + + render() { + const dropdownClassName = classNames('dropdown', 'navbar-search', { open: this.state.open }); + + return ( +
  • + + + + + 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 && + 5 + })}> + {translateWithParameters('select2.tooShort', 2)} + } + + {this.state.open && + Object.keys(this.state.results).length > 0 && +
    (this.node = node)}> +
      + {this.renderComponents()} +
    +
    +
    } +
  • + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js new file mode 100644 index 00000000000..9bf2aa3dfc5 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js @@ -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 ?
    {organization.name}
    : 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 ?
    {project.name}
    : null; + }; + + render() { + const { component } = this.props; + + return ( +
  • this.props.innerRef(component.key, node)}> + + + + {this.renderOrganization(component)} + {this.renderProject(component)} + + + {component.isFavorite && } + {!component.isFavorite && component.isRecentlyBrowsed && } + + + + {component.match + ? + : component.name} + + + +
  • + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js index c84707129f4..8fd6fc4b8e9 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js @@ -54,9 +54,8 @@ class GlobalNavUser extends React.PureComponent { const { currentUser } = this.props; return (
  • - -   - {currentUser.name}  + +
    • diff --git a/server/sonar-web/src/main/js/app/components/nav/global/SearchView.js b/server/sonar-web/src/main/js/app/components/nav/global/SearchView.js deleted file mode 100644 index f0aecd4d887..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/SearchView.js +++ /dev/null @@ -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 = []; - 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); - } -}); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js new file mode 100644 index 00000000000..9ab6beaa056 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js @@ -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( + + ); +} + +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( + + ); + form.instance().openSearch(); + expect(form.state().open).toBe(true); + clickOutside(); + expect(form.state().open).toBe(false); +}); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js new file mode 100644 index 00000000000..73bb358d7a6 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js @@ -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 + + ); +} + +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: 'foo', + 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(); +}); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap new file mode 100644 index 00000000000..faf3d85dffe --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap @@ -0,0 +1,262 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders "Show More" link 1`] = ` +
        +
      • + qualifiers.TRK +
      • + + +
      • + + + show_more + + +
      • +
      • +
      • + qualifiers.BRC +
      • + + +
      +`; + +exports[`renders different components and dividers between them 1`] = ` +
        +
      • + qualifiers.TRK +
      • + + +
      • +
      • + qualifiers.BRC +
      • + + +
      • +
      • + qualifiers.FIL +
      • + +
      +`; + +exports[`shows warning about short input 1`] = ` + + select2.tooShort.2 + +`; + +exports[`shows warning about short input 2`] = ` + + select2.tooShort.2 + +`; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap new file mode 100644 index 00000000000..039ebbd70c1 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap @@ -0,0 +1,323 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders favorite 1`] = ` +
    • + + + + + + + foo + + +
    • +`; + +exports[`renders match 1`] = ` +
    • + + + + + + oo", + } + } + /> + + +
    • +`; + +exports[`renders organizations 1`] = ` +
    • + + +
      + bar +
      + + + + + foo + +
      +
    • +`; + +exports[`renders organizations 2`] = ` +
    • + + + + + + + foo + + +
    • +`; + +exports[`renders projects 1`] = ` +
    • + + +
      + foo +
      + + + + + qwe + +
      +
    • +`; + +exports[`renders recently browsed 1`] = ` +
    • + + + + + + + foo + + +
    • +`; + +exports[`renders selected 1`] = ` +
    • + + + + + + foo + + +
    • +`; + +exports[`renders selected 2`] = ` +
    • + + + + + + foo + + +
    • +`; diff --git a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-empty.hbs b/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-empty.hbs deleted file mode 100644 index fb76e686612..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-empty.hbs +++ /dev/null @@ -1 +0,0 @@ -{{t 'no_results'}} diff --git a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-item.hbs b/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-item.hbs deleted file mode 100644 index 185169fdbda..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-item.hbs +++ /dev/null @@ -1,28 +0,0 @@ -{{#notNull extra}} - {{#gt index 0}} -
      - {{/gt}} - {{#if extra}} - - {{/if}} -{{/notNull}} - - - {{#if organization}} -
      - {{organization.name}} -
      - {{/if}} - - {{#if icon}}{{/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}} -
      diff --git a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search.hbs b/server/sonar-web/src/main/js/app/components/nav/templates/nav-search.hbs deleted file mode 100644 index 68e1f3ad168..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search.hbs +++ /dev/null @@ -1,8 +0,0 @@ - - - - -
      - - diff --git a/server/sonar-web/src/main/js/app/styles/boxed-group.css b/server/sonar-web/src/main/js/app/styles/boxed-group.css index 3b3836cb6cc..c1f569c1206 100644 --- a/server/sonar-web/src/main/js/app/styles/boxed-group.css +++ b/server/sonar-web/src/main/js/app/styles/boxed-group.css @@ -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; diff --git a/server/sonar-web/src/main/js/apps/account/components/UserCard.js b/server/sonar-web/src/main/js/apps/account/components/UserCard.js index 0659fc188e1..f1076d9894a 100644 --- a/server/sonar-web/src/main/js/apps/account/components/UserCard.js +++ b/server/sonar-web/src/main/js/apps/account/components/UserCard.js @@ -31,7 +31,7 @@ export default class UserCard extends React.PureComponent { return (
      - +

      {user.name}

      diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js index 8ec80944e7c..f9854fb1cce 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js +++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js @@ -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} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js index 09609eabe9d..752666243da 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js @@ -95,6 +95,7 @@ export default class AssigneeFacet extends React.PureComponent { {referencedUsers[assignee].name} @@ -115,7 +116,12 @@ export default class AssigneeFacet extends React.PureComponent { return ( {option.avatar != null && - } + } {option.label} ); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap index 4f476fe87e1..f09143436c7 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap @@ -32,6 +32,7 @@ exports[`should render 1`] = ` name-foo @@ -65,6 +66,7 @@ exports[`should render footer select option 1`] = ` name-foo @@ -117,6 +119,7 @@ exports[`should select unassigned 1`] = ` name-foo @@ -177,6 +180,7 @@ exports[`should select user 1`] = ` name-foo diff --git a/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js b/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js index df9a1496503..ff7971ee825 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js @@ -45,7 +45,7 @@ export default class MembersListItem extends React.PureComponent { return ( - + {member.name} diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap index 7d5d1fd90e6..a5635313371 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap @@ -7,6 +7,7 @@ exports[`should groups at 0 if the groupCount field is not defined (just added u > @@ -101,6 +102,7 @@ exports[`should not render actions and groups for non admin 1`] = ` > @@ -126,6 +128,7 @@ exports[`should render actions and groups for admin 1`] = ` > diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.js b/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.js index 18a8a125aa8..8883bbddb9f 100644 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.js +++ b/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.js @@ -59,7 +59,12 @@ export default class UserHolder extends React.PureComponent { {!isCreator && - } + }
      {user.name} diff --git a/server/sonar-web/src/main/js/apps/project-admin/key/BulkUpdate.js b/server/sonar-web/src/main/js/apps/project-admin/key/BulkUpdate.js index 34f9228be1e..90af5ae2f54 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/key/BulkUpdate.js +++ b/server/sonar-web/src/main/js/apps/project-admin/key/BulkUpdate.js @@ -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 = { diff --git a/server/sonar-web/src/main/js/apps/project-admin/key/Key.js b/server/sonar-web/src/main/js/apps/project-admin/key/Key.js index 9ba721d3308..b3f47fc3785 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/key/Key.js +++ b/server/sonar-web/src/main/js/apps/project-admin/key/Key.js @@ -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 { diff --git a/server/sonar-web/src/main/js/apps/sessions/components/Logout.js b/server/sonar-web/src/main/js/apps/sessions/components/Logout.js index 1ff047bb5f0..736c4cc6372 100644 --- a/server/sonar-web/src/main/js/apps/sessions/components/Logout.js +++ b/server/sonar-web/src/main/js/apps/sessions/components/Logout.js @@ -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() { diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js index 125a20a5f0e..e7f4a5ab428 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js +++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js @@ -63,7 +63,7 @@ export default class UsersSelectSearchOption extends React.PureComponent { onMouseMove={this.handleMouseMove} title={user.name}>
      - + {this.props.children} {user.login}
      diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js index 19ad6ab9029..090ad3a0d22 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js +++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js @@ -39,7 +39,7 @@ export default class UsersSelectSearchValue extends React.PureComponent { {user && user.login &&
      - + {this.props.children} {user.login}
      } diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap index 45aa90aba39..80c53be30fe 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap @@ -12,6 +12,7 @@ exports[`should render correctly with email instead of hash 1`] = ` > -
      {{avatarHelper email 36}}
      - -{{/ifShowAvatars}} + +
      {{avatarHelper email name 36}}
      +
      diff --git a/server/sonar-web/src/main/js/helpers/handlebars/avatarHelperNew.js b/server/sonar-web/src/main/js/components/common/ClockIcon.js similarity index 57% rename from server/sonar-web/src/main/js/helpers/handlebars/avatarHelperNew.js rename to server/sonar-web/src/main/js/components/common/ClockIcon.js index 2b65dd68060..8e3a2cecfa8 100644 --- a/server/sonar-web/src/main/js/helpers/handlebars/avatarHelperNew.js +++ b/server/sonar-web/src/main/js/components/common/ClockIcon.js @@ -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 ( + + + + + + + ); } -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( - `` - ); +ClockIcon.defaultProps = { + size: 16 }; diff --git a/server/sonar-web/src/main/js/components/common/DeferredSpinner.js b/server/sonar-web/src/main/js/components/common/DeferredSpinner.js new file mode 100644 index 00000000000..070f705f7be --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/DeferredSpinner.js @@ -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 + ? + : this.props.children || null; + } +} diff --git a/server/sonar-web/src/main/js/components/common/FavoriteIcon.js b/server/sonar-web/src/main/js/components/common/FavoriteIcon.js new file mode 100644 index 00000000000..98157aa9e5d --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/FavoriteIcon.js @@ -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 ( + + + + + + ); +} + +FavoriteIcon.defaultProps = { + size: 16 +}; diff --git a/server/sonar-web/src/main/js/components/common/__tests__/DeferredSpinner-test.js b/server/sonar-web/src/main/js/components/common/__tests__/DeferredSpinner-test.js new file mode 100644 index 00000000000..62d755cc949 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/DeferredSpinner-test.js @@ -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(); + expect(spinner).toMatchSnapshot(); + jest.runAllTimers(); + expect(spinner).toMatchSnapshot(); +}); + +it('add custom className', () => { + const spinner = mount(); + jest.runAllTimers(); + expect(spinner).toMatchSnapshot(); +}); + +it('renders children before timeout', () => { + const spinner = mount(
      foo
      ); + expect(spinner).toMatchSnapshot(); + jest.runAllTimers(); + expect(spinner).toMatchSnapshot(); +}); + +it('is controlled by loading prop', () => { + const spinner = mount(
      foo
      ); + expect(spinner).toMatchSnapshot(); + spinner.setProps({ loading: true }); + expect(spinner).toMatchSnapshot(); + jest.runAllTimers(); + expect(spinner).toMatchSnapshot(); + spinner.setProps({ loading: false }); + expect(spinner).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DeferredSpinner-test.js.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DeferredSpinner-test.js.snap new file mode 100644 index 00000000000..51d17f504c7 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DeferredSpinner-test.js.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`add custom className 1`] = ` + + + +`; + +exports[`is controlled by loading prop 1`] = ` + +
      + foo +
      +
      +`; + +exports[`is controlled by loading prop 2`] = ` + +
      + foo +
      +
      +`; + +exports[`is controlled by loading prop 3`] = ` + + + +`; + +exports[`is controlled by loading prop 4`] = ` + +
      + foo +
      +
      +`; + +exports[`renders children before timeout 1`] = ` + +
      + foo +
      +
      +`; + +exports[`renders children before timeout 2`] = ` + + + +`; + +exports[`renders spinner after timeout 1`] = ` + +`; + +exports[`renders spinner after timeout 2`] = ` + + + +`; diff --git a/server/sonar-web/src/main/js/components/controls/FavoriteBase.js b/server/sonar-web/src/main/js/components/controls/FavoriteBase.js index 648fe040bc0..b417b22e48a 100644 --- a/server/sonar-web/src/main/js/components/controls/FavoriteBase.js +++ b/server/sonar-web/src/main/js/components/controls/FavoriteBase.js @@ -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 ( - - - - ); - } - render() { - const className = classNames( - 'icon-star', - { - 'icon-star-favorite': this.state.favorite - }, - this.props.className - ); - return ( - - {this.renderSVG()} + + ); } diff --git a/server/sonar-web/src/main/js/components/controls/FavoriteBaseStateless.js b/server/sonar-web/src/main/js/components/controls/FavoriteBaseStateless.js index b59055482bd..1e4f325fa1f 100644 --- a/server/sonar-web/src/main/js/components/controls/FavoriteBaseStateless.js +++ b/server/sonar-web/src/main/js/components/controls/FavoriteBaseStateless.js @@ -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 ( - - - - ); - } - render() { - const className = classNames( - 'icon-star', - { - 'icon-star-favorite': this.props.favorite - }, - this.props.className - ); - return ( - - {this.renderSVG()} + + ); } diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/FavoriteBase-test.js b/server/sonar-web/src/main/js/components/controls/__tests__/FavoriteBase-test.js index a22b38993eb..86ab63dbc6c 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/FavoriteBase-test.js +++ b/server/sonar-web/src/main/js/components/controls/__tests__/FavoriteBase-test.js @@ -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', () => { diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/FavoriteBase-test.js.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/FavoriteBase-test.js.snap new file mode 100644 index 00000000000..c7cccad562c --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/FavoriteBase-test.js.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render favorite 1`] = ` + + + +`; + +exports[`should render not favorite 1`] = ` + + + +`; diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueAssign.js b/server/sonar-web/src/main/js/components/issue/components/IssueAssign.js index 55c84072696..b8b6aaa77fe 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueAssign.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueAssign.js @@ -47,7 +47,12 @@ export default class IssueAssign extends React.PureComponent { {issue.assignee && - + } {issue.assignee ? issue.assigneeName : translate('unassigned')} diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.js b/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.js index c82e8c4a0d9..aa37ea0873b 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.js @@ -72,7 +72,12 @@ export default class IssueCommentLine extends React.PureComponent { return (
      - + {comment.authorName}
      @@ -94,6 +95,7 @@ exports[`should render with the action 1`] = ` @@ -118,6 +120,7 @@ exports[`should render without the action when the correct rights are missing 1` diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.js.snap b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.js.snap index fa3f4afc205..c98cf19fd04 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.js.snap +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.js.snap @@ -23,6 +23,7 @@ exports[`should open the right popups when the buttons are clicked 3`] = ` John Doe @@ -117,6 +118,7 @@ exports[`should render correctly a comment that is not updatable 1`] = ` John Doe @@ -153,6 +155,7 @@ exports[`should render correctly a comment that is updatable 1`] = ` John Doe diff --git a/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.js b/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.js index 501f4b2b612..c856a2c94c7 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.js +++ b/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.js @@ -94,9 +94,12 @@ export default class ChangelogPopup extends React.PureComponent { {moment(item.creationDate).format('LLL')} - {item.userName && - item.avatar && - } + {item.userName} diff --git a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js index 933a43c818e..25c1f31a5e8 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js +++ b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js @@ -141,13 +141,13 @@ export default class SetAssigneePopup extends React.PureComponent { onSelect={this.props.onSelect}> {this.state.users.map(user => ( - {(user.avatar || user.email) && - } + diff --git a/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js b/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js index 88d352e9950..3281838da05 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js +++ b/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js @@ -96,6 +96,7 @@ export default class SimilarIssuesPopup extends React.PureComponent { {issue.assigneeName} diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.js.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.js.snap index 8dc76c9e817..8934f554418 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.js.snap +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.js.snap @@ -38,6 +38,7 @@ exports[`should render the changelog popup correctly 1`] = ` john.doe diff --git a/server/sonar-web/src/main/js/components/ui/Avatar.js b/server/sonar-web/src/main/js/components/ui/Avatar.js index b17ce477592..5938676d917 100644 --- a/server/sonar-web/src/main/js/components/ui/Avatar.js +++ b/server/sonar-web/src/main/js/components/ui/Avatar.js @@ -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 ( +
      + {text.toUpperCase()} +
      + ); + } + render() { if (!this.props.enableGravatar) { - return null; + return this.renderFallback(); } const emailHash = this.props.hash || md5.md5((this.props.email || '').toLowerCase()).trim(); diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.js b/server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.js index d52a47ad4ac..1cc40347fd8 100644 --- a/server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.js +++ b/server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.js @@ -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( - - ); - 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( + + ); + expect(avatar).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.js.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.js.snap new file mode 100644 index 00000000000..d51b5ecabcb --- /dev/null +++ b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`falls back to dummy avatar 1`] = ` +
      + FB +
      +`; + +exports[`should be able to render with hash only 1`] = ` + +`; + +exports[`should render 1`] = ` +mail@example.com +`; diff --git a/server/sonar-web/src/main/js/helpers/handlebars/avatarHelper.js b/server/sonar-web/src/main/js/helpers/handlebars/avatarHelper.js index 67220a97d5a..ec05453594f 100644 --- a/server/sonar-web/src/main/js/helpers/handlebars/avatarHelper.js +++ b/server/sonar-web/src/main/js/helpers/handlebars/avatarHelper.js @@ -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( - `${email}` + renderToString( + + + + ) ); }; diff --git a/server/sonar-web/src/main/js/helpers/testUtils.js b/server/sonar-web/src/main/js/helpers/testUtils.js index 96b718cc64b..4ae17931866 100644 --- a/server/sonar-web/src/main/js/helpers/testUtils.js +++ b/server/sonar-web/src/main/js/helpers/testUtils.js @@ -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() {} + }); +}; diff --git a/server/sonar-web/src/main/less/components/menu.less b/server/sonar-web/src/main/less/components/menu.less index 08ff56e2fc3..b8b7a6d7161 100644 --- a/server/sonar-web/src/main/less/components/menu.less +++ b/server/sonar-web/src/main/less/components/menu.less @@ -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 { diff --git a/server/sonar-web/src/main/less/components/navbar.less b/server/sonar-web/src/main/less/components/navbar.less index 605f36ab152..b2c03da21f6 100644 --- a/server/sonar-web/src/main/less/components/navbar.less +++ b/server/sonar-web/src/main/less/components/navbar.less @@ -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; } diff --git a/server/sonar-web/src/main/less/components/tooltips.less b/server/sonar-web/src/main/less/components/tooltips.less index a0a073940c5..ac14eefb361 100644 --- a/server/sonar-web/src/main/less/components/tooltips.less +++ b/server/sonar-web/src/main/less/components/tooltips.less @@ -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 */ ; diff --git a/server/sonar-web/src/main/less/init/icons.less b/server/sonar-web/src/main/less/init/icons.less index f27c923ff21..e0546239f38 100644 --- a/server/sonar-web/src/main/less/init/icons.less +++ b/server/sonar-web/src/main/less/init/icons.less @@ -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'); } diff --git a/server/sonar-web/src/main/less/init/type.less b/server/sonar-web/src/main/less/init/type.less index 7e44c097375..75fe1839413 100644 --- a/server/sonar-web/src/main/less/init/type.less +++ b/server/sonar-web/src/main/less/init/type.less @@ -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); } diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 7671a1d216d..42cdecd025a 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 {0} from anywhere to open this search bar. +search.placeholder=Search for projects, modules and files... #------------------------------------------------------------------------------ -- 2.39.5