@@ -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); | |||
} |
@@ -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 |
@@ -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 |
@@ -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); | |||
} |
@@ -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); |
@@ -1,29 +0,0 @@ | |||
<?xml version="1.0" encoding="UTF-8"?> | |||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> | |||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> | |||
<body> | |||
<table cellpadding="1" cellspacing="1" border="1"> | |||
<tr> | |||
<td>open</td> | |||
<td>/sessions/new</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>content</td> | |||
<td>*Log in with Fake base identity provider*</td> | |||
</tr> | |||
<tr> | |||
<td>click</td> | |||
<td>css=.oauth-providers a</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>id=global-navigation</td> | |||
<td>*John*</td> | |||
</tr> | |||
</table> | |||
</body> | |||
</html> |
@@ -48,11 +48,6 @@ | |||
<td>css=.js-user-authenticated</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>css=.navbar</td> | |||
<td>*Administrator*</td> | |||
</tr> | |||
<tr> | |||
<td>open</td> | |||
<td>/sessions/logout</td> |
@@ -43,14 +43,9 @@ | |||
<td>css=.js-user-authenticated</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>css=.navbar</td> | |||
<td>*Administrator*</td> | |||
</tr> | |||
<tr> | |||
<td>click</td> | |||
<td>Link=Administrator</td> | |||
<td>css=.js-user-authenticated</td> | |||
<td></td> | |||
</tr> | |||
<tr> |
@@ -48,11 +48,6 @@ | |||
<td>css=.js-user-authenticated</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>css=.navbar</td> | |||
<td>*Administrator*</td> | |||
</tr> | |||
<tr> | |||
<td>assertLocation</td> | |||
<td>glob:*/settings?category=general*</td> |
@@ -47,11 +47,6 @@ | |||
<td>css=.js-user-authenticated</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>css=.navbar</td> | |||
<td>*Administrator*</td> | |||
</tr> | |||
<tr> | |||
<td>assertLocation</td> | |||
<td>*/settings</td> |
@@ -57,11 +57,6 @@ | |||
<td>css=.js-user-authenticated</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>css=.navbar</td> | |||
<td>*Administrator*</td> | |||
</tr> | |||
<tr> | |||
<td>assertLocation</td> | |||
<td>*/projects?gate=OK&reliability=1&security=1</td> |
@@ -54,9 +54,9 @@ | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>waitForElementPresent</td> | |||
<td>css=.js-user-authenticated</td> | |||
<td>*simple-user*</td> | |||
<td></td> | |||
</tr> | |||
</tbody> | |||
</table> |
@@ -1,110 +0,0 @@ | |||
<?xml version="1.0" encoding="UTF-8"?> | |||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> | |||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> | |||
<head profile="http://selenium-ide.openqa.org/profiles/test-case"> | |||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> | |||
<link rel="selenium.base" href="http://localhost:49506"/> | |||
<title>should_change_password</title> | |||
</head> | |||
<body> | |||
<table cellpadding="1" cellspacing="1" border="1"> | |||
<thead> | |||
<tr> | |||
<td rowspan="1" colspan="3">should_change_password</td> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
<tr> | |||
<td>open</td> | |||
<td>/sonar/sessions/login</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>type</td> | |||
<td>id=login</td> | |||
<td>account-user</td> | |||
</tr> | |||
<tr> | |||
<td>type</td> | |||
<td>id=password</td> | |||
<td>password</td> | |||
</tr> | |||
<tr> | |||
<td>clickAndWait</td> | |||
<td>commit</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForElementPresent</td> | |||
<td>css=.js-user-authenticated</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>open</td> | |||
<td>/sonar/account/security</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForElementPresent</td> | |||
<td>id=change-password</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>type</td> | |||
<td>id=old_password</td> | |||
<td>password</td> | |||
</tr> | |||
<tr> | |||
<td>type</td> | |||
<td>id=password</td> | |||
<td>new_password</td> | |||
</tr> | |||
<tr> | |||
<td>type</td> | |||
<td>id=password_confirmation</td> | |||
<td>new_password</td> | |||
</tr> | |||
<tr> | |||
<td>click</td> | |||
<td>id=change-password</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForElementPresent</td> | |||
<td>css=.alert-success</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>open</td> | |||
<td>/sonar/sessions/logout</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>open</td> | |||
<td>/sonar/sessions/login</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>type</td> | |||
<td>id=login</td> | |||
<td>account-user</td> | |||
</tr> | |||
<tr> | |||
<td>type</td> | |||
<td>id=password</td> | |||
<td>new_password</td> | |||
</tr> | |||
<tr> | |||
<td>clickAndWait</td> | |||
<td>commit</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>id=global-navigation</td> | |||
<td>*User With Account*</td> | |||
</tr> | |||
</tbody> | |||
</table> | |||
</body> | |||
</html> |
@@ -1,29 +0,0 @@ | |||
<?xml version="1.0" encoding="UTF-8"?> | |||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> | |||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> | |||
<body> | |||
<table cellpadding="1" cellspacing="1" border="1"> | |||
<tr> | |||
<td>open</td> | |||
<td>/sessions/new</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>content</td> | |||
<td>*Log in with Fake oauth2 identity provider*</td> | |||
</tr> | |||
<tr> | |||
<td>click</td> | |||
<td>css=.oauth-providers a</td> | |||
<td></td> | |||
</tr> | |||
<tr> | |||
<td>waitForText</td> | |||
<td>id=global-navigation</td> | |||
<td>*John*</td> | |||
</tr> | |||
</table> | |||
</body> | |||
</html> |
@@ -193,7 +193,7 @@ declare module 'lodash' { | |||
forEach<T: Object>(object: T, iteratee?: OIteratee<T>): T; | |||
forEachRight<T>(array: ?Array<T>, iteratee?: Iteratee<T>): Array<T>; | |||
forEachRight<T: Object>(object: T, iteratee?: OIteratee<T>): T; | |||
groupBy<V, T>(array: ?Array<T>, iteratee?: ValueOnlyIteratee<T>): {[key: V]: ?Array<T>}; | |||
groupBy<V, T>(array: ?Array<T>, iteratee?: ValueOnlyIteratee<T>): {[key: V]: Array<T>}; | |||
groupBy<V, A, T: {[id: string]: A}>(object: T, iteratee?: ValueOnlyIteratee<A>): {[key: V]: ?Array<A>}; | |||
includes<T>(array: ?Array<T>, value: T, fromIndex?: number): bool; | |||
includes<T: Object>(object: T, value: any, fromIndex?: number): bool; |
@@ -166,8 +166,45 @@ export function bulkChangeKey(project: string, from: string, to: string, dryRun? | |||
return postJSON(url, data); | |||
} | |||
export const getSuggestions = (query: string): Promise<Object> => | |||
getJSON('/api/components/suggestions', { s: query }); | |||
export type SuggestionsResponse = { | |||
organizations: Array<{ | |||
key: string, | |||
name: string | |||
}>, | |||
projects: Array<{ | |||
key: string, | |||
name: string | |||
}>, | |||
results: Array<{ | |||
items: Array<{ | |||
isFavorite: boolean, | |||
isRecentlyBrowsed: boolean, | |||
key: string, | |||
match: string, | |||
name: string, | |||
organization: string, | |||
project: string | |||
}>, | |||
more: number, | |||
q: string | |||
}>, | |||
warning?: string | |||
}; | |||
export const getSuggestions = ( | |||
query: string, | |||
recentlyBrowsed?: Array<string>, | |||
more?: string | |||
): Promise<SuggestionsResponse> => { | |||
const data: Object = { s: query }; | |||
if (recentlyBrowsed) { | |||
data.recentlyBrowsed = recentlyBrowsed.join(); | |||
} | |||
if (more) { | |||
data.more = more; | |||
} | |||
return getJSON('/api/components/suggestions', data); | |||
}; | |||
export const getComponentForSourceViewer = (component: string): Promise<*> => | |||
getJSON('/api/components/app', { component }); |
@@ -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( |
@@ -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'; |
@@ -22,7 +22,7 @@ import { connect } from 'react-redux'; | |||
import GlobalNavBranding from './GlobalNavBranding'; | |||
import GlobalNavMenu from './GlobalNavMenu'; | |||
import GlobalNavUser from './GlobalNavUser'; | |||
import GlobalNavSearch from './GlobalNavSearch'; | |||
import GlobalNavSearchForm from './GlobalNavSearchForm'; | |||
import ShortcutsHelpView from './ShortcutsHelpView'; | |||
import { getCurrentUser, getAppState } from '../../../../store/rootReducer'; | |||
@@ -54,6 +54,7 @@ class GlobalNav extends React.PureComponent { | |||
}; | |||
render() { | |||
/* eslint-disable max-len */ | |||
return ( | |||
<nav className="navbar navbar-global page-container" id="global-navigation"> | |||
<div className="container"> | |||
@@ -62,13 +63,20 @@ class GlobalNav extends React.PureComponent { | |||
<GlobalNavMenu {...this.props} /> | |||
<ul className="nav navbar-nav navbar-right"> | |||
<GlobalNavUser {...this.props} /> | |||
<GlobalNavSearch {...this.props} /> | |||
<GlobalNavSearchForm {...this.props} /> | |||
<li> | |||
<a onClick={this.openHelp} href="#"> | |||
<i className="icon-help navbar-icon" /> | |||
<a className="navbar-help" onClick={this.openHelp} href="#"> | |||
<svg width="16" height="16"> | |||
<g transform="matrix(0.0364583,0,0,0.0364583,1,-0.166667)"> | |||
<path | |||
fill="#fff" | |||
d="M224,344L224,296C224,293.667 223.25,291.75 221.75,290.25C220.25,288.75 218.333,288 216,288L168,288C165.667,288 163.75,288.75 162.25,290.25C160.75,291.75 160,293.667 160,296L160,344C160,346.333 160.75,348.25 162.25,349.75C163.75,351.25 165.667,352 168,352L216,352C218.333,352 220.25,351.25 221.75,349.75C223.25,348.25 224,346.333 224,344ZM288,176C288,161.333 283.375,147.75 274.125,135.25C264.875,122.75 253.333,113.083 239.5,106.25C225.667,99.417 211.5,96 197,96C156.5,96 125.583,113.75 104.25,149.25C101.75,153.25 102.417,156.75 106.25,159.75L139.25,184.75C140.417,185.75 142,186.25 144,186.25C146.667,186.25 148.75,185.25 150.25,183.25C159.083,171.917 166.25,164.25 171.75,160.25C177.417,156.25 184.583,154.25 193.25,154.25C201.25,154.25 208.375,156.417 214.625,160.75C220.875,165.083 224,170 224,175.5C224,181.833 222.333,186.917 219,190.75C215.667,194.583 210,198.333 202,202C191.5,206.667 181.875,213.875 173.125,223.625C164.375,233.375 160,243.833 160,255L160,264C160,266.333 160.75,268.25 162.25,269.75C163.75,271.25 165.667,272 168,272L216,272C218.333,272 220.25,271.25 221.75,269.75C223.25,268.25 224,266.333 224,264C224,260.833 225.792,256.708 229.375,251.625C232.958,246.542 237.5,242.417 243,239.25C248.333,236.25 252.417,233.875 255.25,232.125C258.083,230.375 261.917,227.458 266.75,223.375C271.583,219.292 275.292,215.292 277.875,211.375C280.458,207.458 282.792,202.417 284.875,196.25C286.958,190.083 288,183.333 288,176ZM384,224C384,258.833 375.417,290.958 358.25,320.375C341.083,349.792 317.792,373.083 288.375,390.25C258.958,407.417 226.833,416 192,416C157.167,416 125.042,407.417 95.625,390.25C66.208,373.083 42.917,349.792 25.75,320.375C8.583,290.958 0,258.833 0,224C0,189.167 8.583,157.042 25.75,127.625C42.917,98.208 66.208,74.917 95.625,57.75C125.042,40.583 157.167,32 192,32C226.833,32 258.958,40.583 288.375,57.75C317.792,74.917 341.083,98.208 358.25,127.625C375.417,157.042 384,189.167 384,224Z" | |||
/> | |||
</g> | |||
</svg> | |||
</a> | |||
</li> | |||
<GlobalNavUser {...this.props} /> | |||
</ul> | |||
</div> | |||
</nav> |
@@ -1,116 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import Backbone from 'backbone'; | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import key from 'keymaster'; | |||
import SearchView from './SearchView'; | |||
import { getCurrentUser } from '../../../../store/rootReducer'; | |||
function contains(root, node) { | |||
while (node) { | |||
if (node === root) { | |||
return true; | |||
} | |||
node = node.parentNode; | |||
} | |||
return false; | |||
} | |||
class GlobalNavSearch extends React.PureComponent { | |||
state = { open: false }; | |||
componentDidMount() { | |||
key('s', () => { | |||
const isModalOpen = document.querySelector('html').classList.contains('modal-open'); | |||
if (!isModalOpen) { | |||
this.openSearch(); | |||
} | |||
return false; | |||
}); | |||
} | |||
componentWillUnmount() { | |||
this.closeSearch(); | |||
key.unbind('s'); | |||
} | |||
openSearch = () => { | |||
document.addEventListener('click', this.onClickOutside); | |||
this.setState({ open: true }, this.renderSearchView); | |||
}; | |||
closeSearch = () => { | |||
document.removeEventListener('click', this.onClickOutside); | |||
this.resetSearchView(); | |||
this.setState({ open: false }); | |||
}; | |||
resetSearchView = () => { | |||
if (this.searchView) { | |||
this.searchView.destroy(); | |||
} | |||
}; | |||
onClick = e => { | |||
e.preventDefault(); | |||
if (this.state.open) { | |||
this.closeSearch(); | |||
} else { | |||
this.openSearch(); | |||
} | |||
}; | |||
onClickOutside = e => { | |||
if (!contains(this.refs.dropdown, e.target)) { | |||
this.closeSearch(); | |||
} | |||
}; | |||
renderSearchView = () => { | |||
const searchContainer = this.refs.container; | |||
this.searchView = new SearchView({ | |||
model: new Backbone.Model(this.props), | |||
hide: this.closeSearch | |||
}); | |||
this.searchView.render().$el.appendTo(searchContainer); | |||
}; | |||
render() { | |||
const dropdownClassName = 'dropdown' + (this.state.open ? ' open' : ''); | |||
return ( | |||
<li ref="dropdown" className={dropdownClassName}> | |||
<a className="navbar-search-dropdown" href="#" onClick={this.onClick}> | |||
<i className="icon-search navbar-icon" /> <i className="icon-dropdown" /> | |||
</a> | |||
<div | |||
ref="container" | |||
className="dropdown-menu dropdown-menu-right global-navbar-search-dropdown" | |||
/> | |||
</li> | |||
); | |||
} | |||
} | |||
const mapStateToProps = state => ({ | |||
currentUser: getCurrentUser(state) | |||
}); | |||
export default connect(mapStateToProps)(GlobalNavSearch); |
@@ -0,0 +1,419 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import classNames from 'classnames'; | |||
import key from 'keymaster'; | |||
import { debounce, groupBy, keyBy, sortBy, uniqBy } from 'lodash'; | |||
import GlobalNavSearchFormComponent from './GlobalNavSearchFormComponent'; | |||
import type { Component } from './GlobalNavSearchFormComponent'; | |||
import RecentHistory from '../../RecentHistory'; | |||
import DeferredSpinner from '../../../../components/common/DeferredSpinner'; | |||
import { getSuggestions } from '../../../../api/components'; | |||
import { getFavorites } from '../../../../api/favorites'; | |||
import { translate, translateWithParameters } from '../../../../helpers/l10n'; | |||
import { scrollToElement } from '../../../../helpers/scrolling'; | |||
import { getProjectUrl } from '../../../../helpers/urls'; | |||
type Props = {| | |||
appState: { organizationsEnabled: boolean }, | |||
currentUser: { isLoggedIn: boolean } | |||
|}; | |||
type State = { | |||
loading: boolean, | |||
loadingMore: ?string, | |||
more: { [string]: number }, | |||
open: boolean, | |||
organizations: { [string]: { name: string } }, | |||
projects: { [string]: { name: string } }, | |||
query: string, | |||
results: { [qualifier: string]: Array<Component> }, | |||
selected: ?string, | |||
shortQuery: boolean | |||
}; | |||
const ORDER = ['DEV', 'VW', 'SVW', 'TRK', 'BRC', 'FIL', 'UTS']; | |||
export default class GlobalNavSearchForm extends React.PureComponent { | |||
input: HTMLElement; | |||
mounted: boolean; | |||
node: HTMLElement; | |||
nodes: { [string]: HTMLElement }; | |||
props: Props; | |||
state: State; | |||
static contextTypes = { | |||
router: React.PropTypes.object | |||
}; | |||
constructor(props: Props) { | |||
super(props); | |||
this.nodes = {}; | |||
this.search = debounce(this.search, 250); | |||
this.fetchFavoritesAndRecentlyBrowsed = debounce(this.fetchFavoritesAndRecentlyBrowsed, 250, { | |||
leading: true | |||
}); | |||
this.state = { | |||
loading: false, | |||
loadingMore: null, | |||
more: {}, | |||
open: false, | |||
organizations: {}, | |||
projects: {}, | |||
query: '', | |||
results: {}, | |||
selected: null, | |||
shortQuery: false | |||
}; | |||
} | |||
componentDidMount() { | |||
this.mounted = true; | |||
key('s', () => { | |||
this.input.focus(); | |||
this.openSearch(); | |||
return false; | |||
}); | |||
this.fetchFavoritesAndRecentlyBrowsed(); | |||
} | |||
componentWillUpdate() { | |||
this.nodes = {}; | |||
} | |||
componentDidUpdate(prevProps: Props, prevState: State) { | |||
if (prevState.selected !== this.state.selected) { | |||
this.scrollToSelected(); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
key.unbind('s'); | |||
window.removeEventListener('click', this.handleClickOutside); | |||
} | |||
handleClickOutside = (event: { target: HTMLElement }) => { | |||
if (!this.node || !this.node.contains(event.target)) { | |||
this.closeSearch(); | |||
} | |||
}; | |||
openSearch = () => { | |||
window.addEventListener('click', this.handleClickOutside); | |||
if (!this.state.open) { | |||
this.fetchFavoritesAndRecentlyBrowsed(); | |||
} | |||
this.setState({ open: true }); | |||
}; | |||
closeSearch = () => { | |||
if (this.input) { | |||
this.input.blur(); | |||
} | |||
window.removeEventListener('click', this.handleClickOutside); | |||
this.setState({ | |||
more: {}, | |||
open: false, | |||
organizations: {}, | |||
projects: {}, | |||
query: '', | |||
results: {}, | |||
selected: null, | |||
shortQuery: false | |||
}); | |||
}; | |||
getPlainComponentsList = (results: { [qualifier: string]: Array<Component> }): Array<Component> => | |||
this.sortQualifiers(Object.keys(results)).reduce( | |||
(components, qualifier) => [...components, ...results[qualifier]], | |||
[] | |||
); | |||
mergeWithRecentlyBrowsed = (components: Array<Component>) => { | |||
const recentlyBrowsed = RecentHistory.get().map(component => ({ | |||
...component, | |||
isRecentlyBrowsed: true, | |||
qualifier: component.icon.toUpperCase() | |||
})); | |||
return uniqBy([...components, ...recentlyBrowsed], 'key'); | |||
}; | |||
fetchFavoritesAndRecentlyBrowsed = () => { | |||
const done = (components: Array<Component>) => { | |||
const results = groupBy(this.mergeWithRecentlyBrowsed(components), 'qualifier'); | |||
const list = this.getPlainComponentsList(results); | |||
this.setState({ | |||
loading: false, | |||
more: {}, | |||
results, | |||
selected: list.length > 0 ? list[0].key : null | |||
}); | |||
}; | |||
if (this.props.currentUser.isLoggedIn) { | |||
this.setState({ loading: true }); | |||
getFavorites().then(response => { | |||
if (this.mounted) { | |||
done(response.favorites.map(component => ({ ...component, isFavorite: true }))); | |||
} | |||
}); | |||
} else { | |||
done([]); | |||
} | |||
}; | |||
search = (query: string) => { | |||
this.setState({ loading: true }); | |||
const recentlyBrowsed = RecentHistory.get().map(component => component.key); | |||
getSuggestions(query, recentlyBrowsed).then(response => { | |||
if (this.mounted) { | |||
const results = {}; | |||
const more = {}; | |||
response.results.forEach(group => { | |||
results[group.q] = group.items.map(item => ({ ...item, qualifier: group.q })); | |||
more[group.q] = group.more; | |||
}); | |||
const list = this.getPlainComponentsList(results); | |||
this.setState(state => ({ | |||
loading: false, | |||
more, | |||
organizations: { ...state.organizations, ...keyBy(response.organizations, 'key') }, | |||
projects: { ...state.projects, ...keyBy(response.projects, 'key') }, | |||
results, | |||
selected: list.length > 0 ? list[0].key : null, | |||
shortQuery: response.warning === 'short_input' | |||
})); | |||
} | |||
}); | |||
}; | |||
searchMore = (qualifier: string) => { | |||
this.setState({ loading: true, loadingMore: qualifier }); | |||
const recentlyBrowsed = RecentHistory.get().map(component => component.key); | |||
getSuggestions(this.state.query, recentlyBrowsed, qualifier).then(response => { | |||
if (this.mounted) { | |||
const group = response.results.find(group => group.q === qualifier); | |||
const moreResults = (group ? group.items : []).map(item => ({ ...item, qualifier })); | |||
this.setState(state => ({ | |||
loading: false, | |||
loadingMore: null, | |||
more: { ...state.more, [qualifier]: 0 }, | |||
organizations: { ...state.organizations, ...keyBy(response.organizations, 'key') }, | |||
projects: { ...state.projects, ...keyBy(response.projects, 'key') }, | |||
results: { | |||
...state.results, | |||
[qualifier]: uniqBy([...state.results[qualifier], ...moreResults], 'key') | |||
} | |||
})); | |||
} | |||
}); | |||
}; | |||
handleQueryChange = (event: { currentTarget: HTMLInputElement }) => { | |||
const query = event.currentTarget.value; | |||
this.setState({ query, shortQuery: query.length === 1 }); | |||
if (query.length === 0) { | |||
this.fetchFavoritesAndRecentlyBrowsed(); | |||
} else if (query.length >= 2) { | |||
this.search(query); | |||
} | |||
}; | |||
selectPrevious = () => { | |||
this.setState((state: State) => { | |||
const list = this.getPlainComponentsList(state.results); | |||
const index = list.findIndex(component => component.key === state.selected); | |||
return index > 0 ? { selected: list[index - 1].key } : undefined; | |||
}); | |||
}; | |||
selectNext = () => { | |||
this.setState((state: State) => { | |||
const list = this.getPlainComponentsList(state.results); | |||
const index = list.findIndex(component => component.key === state.selected); | |||
return index >= 0 && index < list.length - 1 ? { selected: list[index + 1].key } : undefined; | |||
}); | |||
}; | |||
openSelected = () => { | |||
if (this.state.selected) { | |||
this.context.router.push(getProjectUrl(this.state.selected)); | |||
this.closeSearch(); | |||
} | |||
}; | |||
scrollToSelected = () => { | |||
if (this.state.selected) { | |||
const node = this.nodes[this.state.selected]; | |||
if (node) { | |||
scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node }); | |||
} | |||
} | |||
}; | |||
handleKeyDown = (event: KeyboardEvent) => { | |||
switch (event.keyCode) { | |||
case 13: | |||
event.preventDefault(); | |||
this.openSelected(); | |||
return; | |||
case 27: | |||
event.preventDefault(); | |||
this.closeSearch(); | |||
return; | |||
case 38: | |||
event.preventDefault(); | |||
this.selectPrevious(); | |||
return; | |||
case 40: | |||
event.preventDefault(); | |||
this.selectNext(); | |||
return; | |||
} | |||
}; | |||
handleSelect = (selected: string) => { | |||
this.setState({ selected }); | |||
}; | |||
handleMoreClick = (event: MouseEvent & { currentTarget: HTMLElement }) => { | |||
event.preventDefault(); | |||
event.stopPropagation(); | |||
event.currentTarget.blur(); | |||
const { qualifier } = event.currentTarget.dataset; | |||
this.searchMore(qualifier); | |||
}; | |||
sortQualifiers = (qualifiers: Array<string>) => | |||
sortBy(qualifiers, qualifier => ORDER.indexOf(qualifier)); | |||
innerRef = (component: string, node: HTMLElement) => { | |||
this.nodes[component] = node; | |||
}; | |||
renderComponent = (component: Component) => ( | |||
<GlobalNavSearchFormComponent | |||
appState={this.props.appState} | |||
component={component} | |||
innerRef={this.innerRef} | |||
key={component.key} | |||
onClose={this.closeSearch} | |||
onSelect={this.handleSelect} | |||
organizations={this.state.organizations} | |||
projects={this.state.projects} | |||
selected={this.state.selected === component.key} | |||
/> | |||
); | |||
renderComponents = () => { | |||
const qualifiers = Object.keys(this.state.results); | |||
const renderedComponents = []; | |||
this.sortQualifiers(qualifiers).forEach(qualifier => { | |||
const components = this.state.results[qualifier]; | |||
if (components.length > 0 && renderedComponents.length > 0) { | |||
renderedComponents.push(<li key={`divider-${qualifier}`} className="divider" />); | |||
} | |||
if (components.length > 0) { | |||
renderedComponents.push( | |||
<li key={`header-${qualifier}`} className="dropdown-header"> | |||
{translate('qualifiers', qualifier)} | |||
</li> | |||
); | |||
} | |||
components.forEach(component => { | |||
renderedComponents.push(this.renderComponent(component)); | |||
}); | |||
const more = this.state.more[qualifier]; | |||
if (more != null && more > 0) { | |||
renderedComponents.push( | |||
<li key={`more-${qualifier}`} className="menu-footer"> | |||
<DeferredSpinner | |||
className="navbar-search-icon" | |||
loading={this.state.loadingMore === qualifier}> | |||
<a data-qualifier={qualifier} href="#" onClick={this.handleMoreClick}> | |||
{translate('show_more')} | |||
</a> | |||
</DeferredSpinner> | |||
</li> | |||
); | |||
} | |||
}); | |||
return renderedComponents; | |||
}; | |||
render() { | |||
const dropdownClassName = classNames('dropdown', 'navbar-search', { open: this.state.open }); | |||
return ( | |||
<li className={dropdownClassName}> | |||
<DeferredSpinner className="navbar-search-icon" loading={this.state.loading}> | |||
<i className="navbar-search-icon icon-search" /> | |||
</DeferredSpinner> | |||
<input | |||
autoComplete="off" | |||
className="navbar-search-input js-search-input" | |||
maxLength="30" | |||
name="q" | |||
onChange={this.handleQueryChange} | |||
onClick={event => event.stopPropagation()} | |||
onFocus={this.openSearch} | |||
onKeyDown={this.handleKeyDown} | |||
ref={node => (this.input = node)} | |||
placeholder={translate('search.placeholder')} | |||
type="search" | |||
value={this.state.query} | |||
/> | |||
{this.state.shortQuery && | |||
<span | |||
className={classNames('navbar-search-input-hint', { | |||
'is-shifted': this.state.query.length > 5 | |||
})}> | |||
{translateWithParameters('select2.tooShort', 2)} | |||
</span>} | |||
{this.state.open && | |||
Object.keys(this.state.results).length > 0 && | |||
<div | |||
className="dropdown-menu dropdown-menu-right global-navbar-search-dropdown" | |||
ref={node => (this.node = node)}> | |||
<ul className="menu"> | |||
{this.renderComponents()} | |||
</ul> | |||
<div | |||
className="navbar-search-shortcut-hint" | |||
dangerouslySetInnerHTML={{ | |||
__html: translateWithParameters('search.shortcut_hint', 's') | |||
}} | |||
/> | |||
</div>} | |||
</li> | |||
); | |||
} | |||
} |
@@ -0,0 +1,113 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import { Link } from 'react-router'; | |||
import FavoriteIcon from '../../../../components/common/FavoriteIcon'; | |||
import QualifierIcon from '../../../../components/shared/QualifierIcon'; | |||
import ClockIcon from '../../../../components/common/ClockIcon'; | |||
import Tooltip from '../../../../components/controls/Tooltip'; | |||
import { getProjectUrl } from '../../../../helpers/urls'; | |||
export type Component = { | |||
isFavorite?: boolean, | |||
isRecentlyBrowsed?: boolean, | |||
key: string, | |||
match?: string, | |||
name: string, | |||
organization?: string, | |||
project?: string, | |||
qualifier: string | |||
}; | |||
type Props = {| | |||
appState: { organizationsEnabled: boolean }, | |||
component: Component, | |||
innerRef: (string, HTMLElement) => void, | |||
onClose: () => void, | |||
onSelect: string => void, | |||
organizations: { [string]: { name: string } }, | |||
projects: { [string]: { name: string } }, | |||
selected: boolean | |||
|}; | |||
export default class GlobalNavSearchFormComponent extends React.PureComponent { | |||
props: Props; | |||
handleMouseEnter = () => { | |||
this.props.onSelect(this.props.component.key); | |||
}; | |||
renderOrganization = (component: Component) => { | |||
if (!this.props.appState.organizationsEnabled) { | |||
return null; | |||
} | |||
if (!['VW', 'SVW', 'TRK'].includes(component.qualifier) || component.organization == null) { | |||
return null; | |||
} | |||
const organization = this.props.organizations[component.organization]; | |||
return organization ? <div className="pull-right text-muted-2">{organization.name}</div> : null; | |||
}; | |||
renderProject = (component: Component) => { | |||
if (!['BRC', 'FIL', 'UTS'].includes(component.qualifier) || component.project == null) { | |||
return null; | |||
} | |||
const project = this.props.projects[component.project]; | |||
return project ? <div className="pull-right text-muted-2">{project.name}</div> : null; | |||
}; | |||
render() { | |||
const { component } = this.props; | |||
return ( | |||
<li | |||
className={this.props.selected ? 'active' : undefined} | |||
key={component.key} | |||
ref={node => this.props.innerRef(component.key, node)}> | |||
<Tooltip mouseEnterDelay={1.0} overlay={component.key} placement="left"> | |||
<Link | |||
data-key={component.key} | |||
onClick={this.props.onClose} | |||
onMouseEnter={this.handleMouseEnter} | |||
to={getProjectUrl(component.key)}> | |||
{this.renderOrganization(component)} | |||
{this.renderProject(component)} | |||
<span className="navbar-search-item-icons little-spacer-right"> | |||
{component.isFavorite && <FavoriteIcon favorite={true} size={12} />} | |||
{!component.isFavorite && component.isRecentlyBrowsed && <ClockIcon size={12} />} | |||
<QualifierIcon className="little-spacer-right" qualifier={component.qualifier} /> | |||
</span> | |||
{component.match | |||
? <span dangerouslySetInnerHTML={{ __html: component.match }} /> | |||
: component.name} | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
); | |||
} | |||
} |
@@ -54,9 +54,8 @@ class GlobalNavUser extends React.PureComponent { | |||
const { currentUser } = this.props; | |||
return ( | |||
<li className="dropdown js-user-authenticated"> | |||
<a className="dropdown-toggle" data-toggle="dropdown" href="#"> | |||
<Avatar email={currentUser.email} size={20} /> | |||
{currentUser.name} <i className="icon-dropdown" /> | |||
<a className="dropdown-toggle navbar-avatar" data-toggle="dropdown" href="#"> | |||
<Avatar email={currentUser.email} name={currentUser.name} size={24} /> | |||
</a> | |||
<ul className="dropdown-menu dropdown-menu-right"> | |||
<li> |
@@ -1,303 +0,0 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
// @flow | |||
import Backbone from 'backbone'; | |||
import Marionette from 'backbone.marionette'; | |||
import { debounce, sortBy } from 'lodash'; | |||
import SelectableCollectionView from '../../../../components/common/selectable-collection-view'; | |||
import SearchItemTemplate from '../templates/nav-search-item.hbs'; | |||
import EmptySearchTemplate from '../templates/nav-search-empty.hbs'; | |||
import SearchTemplate from '../templates/nav-search.hbs'; | |||
import RecentHistory from '../component/RecentHistory'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { isUserAdmin } from '../../../../helpers/users'; | |||
import { getFavorites } from '../../../../api/favorites'; | |||
import { getSuggestions } from '../../../../api/components'; | |||
import { | |||
getOrganization, | |||
areThereCustomOrganizations | |||
} from '../../../../store/organizations/utils'; | |||
type Finding = { | |||
name: string, | |||
url: string, | |||
extra?: string | |||
}; | |||
const SHOW_ORGANIZATION_FOR_QUALIFIERS = ['TRK', 'VW', 'SVW']; | |||
const SearchItemView = Marionette.ItemView.extend({ | |||
tagName: 'li', | |||
template: SearchItemTemplate, | |||
select() { | |||
this.$el.addClass('active'); | |||
}, | |||
deselect() { | |||
this.$el.removeClass('active'); | |||
}, | |||
submit() { | |||
this.$('a')[0].click(); | |||
}, | |||
onRender() { | |||
this.$('[data-toggle="tooltip"]').tooltip({ | |||
container: 'body', | |||
html: true, | |||
placement: 'left', | |||
delay: { show: 500, hide: 0 } | |||
}); | |||
}, | |||
onDestroy() { | |||
this.$('[data-toggle="tooltip"]').tooltip('destroy'); | |||
}, | |||
serializeData() { | |||
return { | |||
...Marionette.ItemView.prototype.serializeData.apply(this, arguments), | |||
index: this.options.index | |||
}; | |||
} | |||
}); | |||
const SearchEmptyView = Marionette.ItemView.extend({ | |||
tagName: 'li', | |||
template: EmptySearchTemplate | |||
}); | |||
const SearchResultsView = SelectableCollectionView.extend({ | |||
className: 'menu', | |||
tagName: 'ul', | |||
childView: SearchItemView, | |||
emptyView: SearchEmptyView | |||
}); | |||
export default Marionette.LayoutView.extend({ | |||
className: 'navbar-search', | |||
tagName: 'form', | |||
template: SearchTemplate, | |||
regions: { | |||
resultsRegion: '.js-search-results' | |||
}, | |||
events: { | |||
submit: 'handleSubmit', | |||
'keydown .js-search-input': 'onKeyDown', | |||
'keyup .js-search-input': 'onKeyUp' | |||
}, | |||
initialize() { | |||
this.results = new Backbone.Collection(); | |||
this.favorite = []; | |||
if (this.model.get('currentUser').isLoggedIn) { | |||
this.fetchFavorite().then( | |||
() => this.resetResultsToDefault(), | |||
() => this.resetResultsToDefault() | |||
); | |||
} else { | |||
this.resetResultsToDefault(); | |||
} | |||
this.resultsView = new SearchResultsView({ collection: this.results }); | |||
this.debouncedSearch = debounce(this.search, 250); | |||
this._bufferedValue = ''; | |||
}, | |||
onRender() { | |||
const that = this; | |||
this.resultsRegion.show(this.resultsView); | |||
setTimeout(() => { | |||
that.$('.js-search-input').focus(); | |||
}, 0); | |||
}, | |||
onKeyDown(e) { | |||
if (e.keyCode === 38) { | |||
this.resultsView.selectPrev(); | |||
return false; | |||
} | |||
if (e.keyCode === 40) { | |||
this.resultsView.selectNext(); | |||
return false; | |||
} | |||
if (e.keyCode === 13) { | |||
this.resultsView.submitCurrent(); | |||
this.destroy(); | |||
return false; | |||
} | |||
if (e.keyCode === 27) { | |||
this.options.hide(); | |||
return false; | |||
} | |||
}, | |||
onKeyUp() { | |||
const value = this.$('.js-search-input').val(); | |||
if (value === this._bufferedValue) { | |||
return; | |||
} | |||
this._bufferedValue = this.$('.js-search-input').val(); | |||
this.searchRequest = this.debouncedSearch(value); | |||
}, | |||
onSubmit() { | |||
return false; | |||
}, | |||
fetchFavorite(): Promise<*> { | |||
const customOrganizations = areThereCustomOrganizations(); | |||
return getFavorites().then(r => { | |||
this.favorite = r.favorites.map(f => { | |||
const showOrganization = customOrganizations && f.organization != null; | |||
const organization = showOrganization ? getOrganization(f.organization) : null; | |||
return { | |||
url: window.baseUrl + | |||
'/dashboard/index?id=' + | |||
encodeURIComponent(f.key) + | |||
window.dashboardParameters(true), | |||
name: f.name, | |||
icon: 'favorite', | |||
organization | |||
}; | |||
}); | |||
this.favorite = sortBy(this.favorite, 'name'); | |||
}); | |||
}, | |||
resetResultsToDefault() { | |||
const recentHistory = RecentHistory.get(); | |||
const customOrganizations = areThereCustomOrganizations(); | |||
const history = recentHistory.map((historyItem, index) => { | |||
const url = | |||
window.baseUrl + | |||
'/dashboard/index?id=' + | |||
encodeURIComponent(historyItem.key) + | |||
window.dashboardParameters(true); | |||
const showOrganization = customOrganizations && historyItem.organization != null; | |||
// $FlowFixMe flow doesn't check the above condition on `historyItem.organization != null` | |||
const organization = showOrganization ? getOrganization(historyItem.organization) : null; | |||
return { | |||
url, | |||
organization, | |||
name: historyItem.name, | |||
q: historyItem.icon, | |||
extra: index === 0 ? translate('browsed_recently') : null | |||
}; | |||
}); | |||
const favorite = this.favorite.slice(0, 6).map((f, index) => { | |||
return { ...f, extra: index === 0 ? translate('favorite') : null }; | |||
}); | |||
this.results.reset([].concat(history, favorite)); | |||
}, | |||
search(q) { | |||
if (q.length < 2) { | |||
this.resetResultsToDefault(); | |||
return; | |||
} | |||
return getSuggestions(q).then(r => { | |||
// if the input value has changed since we sent the request, | |||
// just ignore the output, because another request already sent | |||
if (q !== this._bufferedValue) { | |||
return; | |||
} | |||
const customOrganizations = areThereCustomOrganizations(); | |||
const collection = []; | |||
r.results.forEach(({ items, q }) => { | |||
items.forEach((item, index) => { | |||
const showOrganization = | |||
customOrganizations && | |||
item.organization != null && | |||
SHOW_ORGANIZATION_FOR_QUALIFIERS.includes(q); | |||
const organization = showOrganization ? getOrganization(item.organization) : null; | |||
collection.push({ | |||
...item, | |||
q, | |||
organization, | |||
extra: index === 0 ? translate('qualifiers', q) : null, | |||
url: window.baseUrl + '/dashboard?id=' + encodeURIComponent(item.key) | |||
}); | |||
}); | |||
}); | |||
this.results.reset([ | |||
...this.getNavigationFindings(q), | |||
...this.getGlobalDashboardFindings(q), | |||
...this.getFavoriteFindings(q), | |||
...collection | |||
]); | |||
}); | |||
}, | |||
getNavigationFindings(q) { | |||
const DEFAULT_ITEMS = [ | |||
{ name: translate('issues.page'), url: window.baseUrl + '/issues' }, | |||
{ | |||
name: translate('layout.measures'), | |||
url: window.baseUrl + '/measures/search?qualifiers[]=TRK' | |||
}, | |||
{ name: translate('coding_rules.page'), url: window.baseUrl + '/coding_rules' }, | |||
{ name: translate('quality_profiles.page'), url: window.baseUrl + '/profiles' }, | |||
{ name: translate('quality_gates.page'), url: window.baseUrl + '/quality_gates' } | |||
]; | |||
const customItems: Array<Finding> = []; | |||
if (isUserAdmin(this.model.get('currentUser'))) { | |||
customItems.push({ name: translate('layout.settings'), url: window.baseUrl + '/settings' }); | |||
} | |||
const findings = [].concat(DEFAULT_ITEMS, customItems).filter(f => { | |||
return f.name.match(new RegExp(q, 'i')); | |||
}); | |||
if (findings.length > 0) { | |||
findings[0].extra = translate('navigation'); | |||
} | |||
return findings.slice(0, 6); | |||
}, | |||
getGlobalDashboardFindings(q) { | |||
const dashboards = this.model.get('globalDashboards') || []; | |||
const items = dashboards.map(d => { | |||
return { | |||
name: d.name, | |||
url: window.baseUrl + '/dashboard/index?did=' + encodeURIComponent(d.key) | |||
}; | |||
}); | |||
const findings = items.filter(f => { | |||
return f.name.match(new RegExp(q, 'i')); | |||
}); | |||
if (findings.length > 0) { | |||
findings[0].extra = translate('dashboard.global_dashboards'); | |||
} | |||
return findings.slice(0, 6); | |||
}, | |||
getFavoriteFindings(q) { | |||
const findings = this.favorite.filter(f => { | |||
return f.name.match(new RegExp(q, 'i')); | |||
}); | |||
if (findings.length > 0) { | |||
findings[0].extra = translate('favorite'); | |||
} | |||
return findings.slice(0, 6); | |||
} | |||
}); |
@@ -0,0 +1,140 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import React from 'react'; | |||
import { shallow, mount } from 'enzyme'; | |||
import type { ShallowWrapper } from 'enzyme'; | |||
import GlobalNavSearchForm from '../GlobalNavSearchForm'; | |||
import { elementKeydown, clickOutside } from '../../../../../helpers/testUtils'; | |||
function render(props?: Object) { | |||
return shallow( | |||
<GlobalNavSearchForm | |||
appState={{ organizationsEnabled: false }} | |||
currentUser={{ isLoggedIn: false }} | |||
{...props} | |||
/> | |||
); | |||
} | |||
function component(key: string, qualifier: string = 'TRK') { | |||
return { key, name: key, qualifier }; | |||
} | |||
function next(form: ShallowWrapper, expected: string) { | |||
elementKeydown(form.find('input'), 40); | |||
expect(form.state().selected).toBe(expected); | |||
} | |||
function prev(form: ShallowWrapper, expected: string) { | |||
elementKeydown(form.find('input'), 38); | |||
expect(form.state().selected).toBe(expected); | |||
} | |||
function select(form: ShallowWrapper, expected: string) { | |||
form.instance().handleSelect(expected); | |||
expect(form.state().selected).toBe(expected); | |||
} | |||
it('renders different components and dividers between them', () => { | |||
const form = render(); | |||
form.setState({ | |||
open: true, | |||
results: { | |||
TRK: [component('foo'), component('bar')], | |||
BRC: [component('qwe', 'BRC'), component('qux', 'BRC')], | |||
FIL: [component('zux', 'FIL')] | |||
} | |||
}); | |||
expect(form.find('.menu')).toMatchSnapshot(); | |||
}); | |||
it('renders "Show More" link', () => { | |||
const form = render(); | |||
form.setState({ | |||
more: { TRK: 175, BRC: 0 }, | |||
open: true, | |||
results: { | |||
TRK: [component('foo'), component('bar')], | |||
BRC: [component('qwe', 'BRC'), component('qux', 'BRC')] | |||
} | |||
}); | |||
expect(form.find('.menu')).toMatchSnapshot(); | |||
}); | |||
it('selects results', () => { | |||
const form = render(); | |||
form.setState({ | |||
open: true, | |||
results: { | |||
TRK: [component('foo'), component('bar')], | |||
BRC: [component('qwe', 'BRC')] | |||
}, | |||
selected: 'foo' | |||
}); | |||
expect(form.state().selected).toBe('foo'); | |||
next(form, 'bar'); | |||
next(form, 'qwe'); | |||
next(form, 'qwe'); | |||
prev(form, 'bar'); | |||
select(form, 'foo'); | |||
prev(form, 'foo'); | |||
}); | |||
it('opens selected on enter', () => { | |||
const form = render(); | |||
form.setState({ | |||
open: true, | |||
results: { TRK: [component('foo')] }, | |||
selected: 'foo' | |||
}); | |||
const openSelected = jest.fn(); | |||
form.instance().openSelected = openSelected; | |||
elementKeydown(form.find('input'), 13); | |||
expect(openSelected).toBeCalled(); | |||
}); | |||
it('shows warning about short input', () => { | |||
const form = render(); | |||
form.setState({ shortQuery: true }); | |||
expect(form.find('.navbar-search-input-hint')).toMatchSnapshot(); | |||
form.setState({ query: 'foobar x' }); | |||
expect(form.find('.navbar-search-input-hint')).toMatchSnapshot(); | |||
}); | |||
it('closes on escape', () => { | |||
const form = render(); | |||
form.instance().openSearch(); | |||
expect(form.state().open).toBe(true); | |||
elementKeydown(form.find('input'), 27); | |||
expect(form.state().open).toBe(false); | |||
}); | |||
it('closes on click outside', () => { | |||
const form = mount( | |||
<GlobalNavSearchForm | |||
appState={{ organizationsEnabled: false }} | |||
currentUser={{ isLoggedIn: false }} | |||
/> | |||
); | |||
form.instance().openSearch(); | |||
expect(form.state().open).toBe(true); | |||
clickOutside(); | |||
expect(form.state().open).toBe(false); | |||
}); |
@@ -0,0 +1,109 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import GlobalNavSearchFormComponent from '../GlobalNavSearchFormComponent'; | |||
function render(props?: Object) { | |||
return shallow( | |||
// $FlowFixMe | |||
<GlobalNavSearchFormComponent | |||
appState={{ organizationsEnabled: false }} | |||
component={{ key: 'foo', name: 'foo', qualifier: 'TRK', organization: 'bar' }} | |||
innerRef={jest.fn()} | |||
onClose={jest.fn()} | |||
onSelect={jest.fn()} | |||
organizations={{ bar: { name: 'bar' } }} | |||
projects={{ foo: { name: 'foo' } }} | |||
selected={false} | |||
{...props} | |||
/> | |||
); | |||
} | |||
it('renders selected', () => { | |||
const wrapper = render(); | |||
expect(wrapper).toMatchSnapshot(); | |||
wrapper.setProps({ selected: true }); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('renders match', () => { | |||
const component = { | |||
key: 'foo', | |||
name: 'foo', | |||
match: 'f<mark>o</mark>o', | |||
qualifier: 'TRK', | |||
organization: 'bar' | |||
}; | |||
const wrapper = render({ component }); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('renders favorite', () => { | |||
const component = { | |||
isFavorite: true, | |||
key: 'foo', | |||
name: 'foo', | |||
qualifier: 'TRK', | |||
organization: 'bar' | |||
}; | |||
const wrapper = render({ component }); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('renders recently browsed', () => { | |||
const component = { | |||
isRecentlyBrowsed: true, | |||
key: 'foo', | |||
name: 'foo', | |||
qualifier: 'TRK', | |||
organization: 'bar' | |||
}; | |||
const wrapper = render({ component }); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('renders projects', () => { | |||
const component = { | |||
isRecentlyBrowsed: true, | |||
key: 'qwe', | |||
name: 'qwe', | |||
qualifier: 'BRC', | |||
project: 'foo' | |||
}; | |||
const wrapper = render({ component }); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
it('renders organizations', () => { | |||
const component = { | |||
isRecentlyBrowsed: true, | |||
key: 'foo', | |||
name: 'foo', | |||
qualifier: 'TRK', | |||
organization: 'bar' | |||
}; | |||
const wrapper = render({ appState: { organizationsEnabled: true }, component }); | |||
expect(wrapper).toMatchSnapshot(); | |||
wrapper.setProps({ appState: { organizationsEnabled: false } }); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); |
@@ -0,0 +1,262 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`renders "Show More" link 1`] = ` | |||
<ul | |||
className="menu" | |||
> | |||
<li | |||
className="dropdown-header" | |||
> | |||
qualifiers.TRK | |||
</li> | |||
<GlobalNavSearchFormComponent | |||
appState={ | |||
Object { | |||
"organizationsEnabled": false, | |||
} | |||
} | |||
component={ | |||
Object { | |||
"key": "foo", | |||
"name": "foo", | |||
"qualifier": "TRK", | |||
} | |||
} | |||
innerRef={[Function]} | |||
onClose={[Function]} | |||
onSelect={[Function]} | |||
organizations={Object {}} | |||
projects={Object {}} | |||
selected={false} | |||
/> | |||
<GlobalNavSearchFormComponent | |||
appState={ | |||
Object { | |||
"organizationsEnabled": false, | |||
} | |||
} | |||
component={ | |||
Object { | |||
"key": "bar", | |||
"name": "bar", | |||
"qualifier": "TRK", | |||
} | |||
} | |||
innerRef={[Function]} | |||
onClose={[Function]} | |||
onSelect={[Function]} | |||
organizations={Object {}} | |||
projects={Object {}} | |||
selected={false} | |||
/> | |||
<li | |||
className="menu-footer" | |||
> | |||
<DeferredSpinner | |||
className="navbar-search-icon" | |||
loading={false} | |||
timeout={100} | |||
> | |||
<a | |||
data-qualifier="TRK" | |||
href="#" | |||
onClick={[Function]} | |||
> | |||
show_more | |||
</a> | |||
</DeferredSpinner> | |||
</li> | |||
<li | |||
className="divider" | |||
/> | |||
<li | |||
className="dropdown-header" | |||
> | |||
qualifiers.BRC | |||
</li> | |||
<GlobalNavSearchFormComponent | |||
appState={ | |||
Object { | |||
"organizationsEnabled": false, | |||
} | |||
} | |||
component={ | |||
Object { | |||
"key": "qwe", | |||
"name": "qwe", | |||
"qualifier": "BRC", | |||
} | |||
} | |||
innerRef={[Function]} | |||
onClose={[Function]} | |||
onSelect={[Function]} | |||
organizations={Object {}} | |||
projects={Object {}} | |||
selected={false} | |||
/> | |||
<GlobalNavSearchFormComponent | |||
appState={ | |||
Object { | |||
"organizationsEnabled": false, | |||
} | |||
} | |||
component={ | |||
Object { | |||
"key": "qux", | |||
"name": "qux", | |||
"qualifier": "BRC", | |||
} | |||
} | |||
innerRef={[Function]} | |||
onClose={[Function]} | |||
onSelect={[Function]} | |||
organizations={Object {}} | |||
projects={Object {}} | |||
selected={false} | |||
/> | |||
</ul> | |||
`; | |||
exports[`renders different components and dividers between them 1`] = ` | |||
<ul | |||
className="menu" | |||
> | |||
<li | |||
className="dropdown-header" | |||
> | |||
qualifiers.TRK | |||
</li> | |||
<GlobalNavSearchFormComponent | |||
appState={ | |||
Object { | |||
"organizationsEnabled": false, | |||
} | |||
} | |||
component={ | |||
Object { | |||
"key": "foo", | |||
"name": "foo", | |||
"qualifier": "TRK", | |||
} | |||
} | |||
innerRef={[Function]} | |||
onClose={[Function]} | |||
onSelect={[Function]} | |||
organizations={Object {}} | |||
projects={Object {}} | |||
selected={false} | |||
/> | |||
<GlobalNavSearchFormComponent | |||
appState={ | |||
Object { | |||
"organizationsEnabled": false, | |||
} | |||
} | |||
component={ | |||
Object { | |||
"key": "bar", | |||
"name": "bar", | |||
"qualifier": "TRK", | |||
} | |||
} | |||
innerRef={[Function]} | |||
onClose={[Function]} | |||
onSelect={[Function]} | |||
organizations={Object {}} | |||
projects={Object {}} | |||
selected={false} | |||
/> | |||
<li | |||
className="divider" | |||
/> | |||
<li | |||
className="dropdown-header" | |||
> | |||
qualifiers.BRC | |||
</li> | |||
<GlobalNavSearchFormComponent | |||
appState={ | |||
Object { | |||
"organizationsEnabled": false, | |||
} | |||
} | |||
component={ | |||
Object { | |||
"key": "qwe", | |||
"name": "qwe", | |||
"qualifier": "BRC", | |||
} | |||
} | |||
innerRef={[Function]} | |||
onClose={[Function]} | |||
onSelect={[Function]} | |||
organizations={Object {}} | |||
projects={Object {}} | |||
selected={false} | |||
/> | |||
<GlobalNavSearchFormComponent | |||
appState={ | |||
Object { | |||
"organizationsEnabled": false, | |||
} | |||
} | |||
component={ | |||
Object { | |||
"key": "qux", | |||
"name": "qux", | |||
"qualifier": "BRC", | |||
} | |||
} | |||
innerRef={[Function]} | |||
onClose={[Function]} | |||
onSelect={[Function]} | |||
organizations={Object {}} | |||
projects={Object {}} | |||
selected={false} | |||
/> | |||
<li | |||
className="divider" | |||
/> | |||
<li | |||
className="dropdown-header" | |||
> | |||
qualifiers.FIL | |||
</li> | |||
<GlobalNavSearchFormComponent | |||
appState={ | |||
Object { | |||
"organizationsEnabled": false, | |||
} | |||
} | |||
component={ | |||
Object { | |||
"key": "zux", | |||
"name": "zux", | |||
"qualifier": "FIL", | |||
} | |||
} | |||
innerRef={[Function]} | |||
onClose={[Function]} | |||
onSelect={[Function]} | |||
organizations={Object {}} | |||
projects={Object {}} | |||
selected={false} | |||
/> | |||
</ul> | |||
`; | |||
exports[`shows warning about short input 1`] = ` | |||
<span | |||
className="navbar-search-input-hint" | |||
> | |||
select2.tooShort.2 | |||
</span> | |||
`; | |||
exports[`shows warning about short input 2`] = ` | |||
<span | |||
className="navbar-search-input-hint is-shifted" | |||
> | |||
select2.tooShort.2 | |||
</span> | |||
`; |
@@ -0,0 +1,323 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`renders favorite 1`] = ` | |||
<li> | |||
<Tooltip | |||
mouseEnterDelay={1} | |||
overlay="foo" | |||
placement="left" | |||
> | |||
<Link | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/dashboard", | |||
"query": Object { | |||
"id": "foo", | |||
}, | |||
} | |||
} | |||
> | |||
<span | |||
className="navbar-search-item-icons little-spacer-right" | |||
> | |||
<FavoriteIcon | |||
favorite={true} | |||
size={12} | |||
/> | |||
<QualifierIcon | |||
className="little-spacer-right" | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
`; | |||
exports[`renders match 1`] = ` | |||
<li> | |||
<Tooltip | |||
mouseEnterDelay={1} | |||
overlay="foo" | |||
placement="left" | |||
> | |||
<Link | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/dashboard", | |||
"query": Object { | |||
"id": "foo", | |||
}, | |||
} | |||
} | |||
> | |||
<span | |||
className="navbar-search-item-icons little-spacer-right" | |||
> | |||
<QualifierIcon | |||
className="little-spacer-right" | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
<span | |||
dangerouslySetInnerHTML={ | |||
Object { | |||
"__html": "f<mark>o</mark>o", | |||
} | |||
} | |||
/> | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
`; | |||
exports[`renders organizations 1`] = ` | |||
<li> | |||
<Tooltip | |||
mouseEnterDelay={1} | |||
overlay="foo" | |||
placement="left" | |||
> | |||
<Link | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/dashboard", | |||
"query": Object { | |||
"id": "foo", | |||
}, | |||
} | |||
} | |||
> | |||
<div | |||
className="pull-right text-muted-2" | |||
> | |||
bar | |||
</div> | |||
<span | |||
className="navbar-search-item-icons little-spacer-right" | |||
> | |||
<ClockIcon | |||
size={12} | |||
/> | |||
<QualifierIcon | |||
className="little-spacer-right" | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
`; | |||
exports[`renders organizations 2`] = ` | |||
<li> | |||
<Tooltip | |||
mouseEnterDelay={1} | |||
overlay="foo" | |||
placement="left" | |||
> | |||
<Link | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/dashboard", | |||
"query": Object { | |||
"id": "foo", | |||
}, | |||
} | |||
} | |||
> | |||
<span | |||
className="navbar-search-item-icons little-spacer-right" | |||
> | |||
<ClockIcon | |||
size={12} | |||
/> | |||
<QualifierIcon | |||
className="little-spacer-right" | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
`; | |||
exports[`renders projects 1`] = ` | |||
<li> | |||
<Tooltip | |||
mouseEnterDelay={1} | |||
overlay="qwe" | |||
placement="left" | |||
> | |||
<Link | |||
data-key="qwe" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/dashboard", | |||
"query": Object { | |||
"id": "qwe", | |||
}, | |||
} | |||
} | |||
> | |||
<div | |||
className="pull-right text-muted-2" | |||
> | |||
foo | |||
</div> | |||
<span | |||
className="navbar-search-item-icons little-spacer-right" | |||
> | |||
<ClockIcon | |||
size={12} | |||
/> | |||
<QualifierIcon | |||
className="little-spacer-right" | |||
qualifier="BRC" | |||
/> | |||
</span> | |||
qwe | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
`; | |||
exports[`renders recently browsed 1`] = ` | |||
<li> | |||
<Tooltip | |||
mouseEnterDelay={1} | |||
overlay="foo" | |||
placement="left" | |||
> | |||
<Link | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/dashboard", | |||
"query": Object { | |||
"id": "foo", | |||
}, | |||
} | |||
} | |||
> | |||
<span | |||
className="navbar-search-item-icons little-spacer-right" | |||
> | |||
<ClockIcon | |||
size={12} | |||
/> | |||
<QualifierIcon | |||
className="little-spacer-right" | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
`; | |||
exports[`renders selected 1`] = ` | |||
<li> | |||
<Tooltip | |||
mouseEnterDelay={1} | |||
overlay="foo" | |||
placement="left" | |||
> | |||
<Link | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/dashboard", | |||
"query": Object { | |||
"id": "foo", | |||
}, | |||
} | |||
} | |||
> | |||
<span | |||
className="navbar-search-item-icons little-spacer-right" | |||
> | |||
<QualifierIcon | |||
className="little-spacer-right" | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
`; | |||
exports[`renders selected 2`] = ` | |||
<li | |||
className="active" | |||
> | |||
<Tooltip | |||
mouseEnterDelay={1} | |||
overlay="foo" | |||
placement="left" | |||
> | |||
<Link | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/dashboard", | |||
"query": Object { | |||
"id": "foo", | |||
}, | |||
} | |||
} | |||
> | |||
<span | |||
className="navbar-search-item-icons little-spacer-right" | |||
> | |||
<QualifierIcon | |||
className="little-spacer-right" | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
`; |
@@ -1 +0,0 @@ | |||
<span class="note">{{t 'no_results'}}</span> |
@@ -1,28 +0,0 @@ | |||
{{#notNull extra}} | |||
{{#gt index 0}} | |||
<div class="divider"></div> | |||
{{/gt}} | |||
{{#if extra}} | |||
<div class="dropdown-header">{{extra}}</div> | |||
{{/if}} | |||
{{/notNull}} | |||
<a href="{{this.url}}" data-title="{{name}}<br>{{key}}" data-toggle="tooltip"> | |||
{{#if organization}} | |||
<div class="pull-right nowrap note"> | |||
{{organization.name}} | |||
</div> | |||
{{/if}} | |||
{{#if icon}}<i class="icon-{{icon}} text-text-bottom"></i>{{/if}} | |||
{{#if q}}{{qualifierIcon q}}{{/if}} | |||
{{#eq q 'FIL'}} | |||
{{collapsedDirFromPath name}}{{fileFromPath name}} | |||
{{else}} | |||
{{#eq q 'UTS'}} | |||
{{collapsedDirFromPath name}}{{fileFromPath name}} | |||
{{else}} | |||
{{name}} | |||
{{/eq}} | |||
{{/eq}} | |||
</a> |
@@ -1,8 +0,0 @@ | |||
<i class="navbar-search-icon icon-search"></i> | |||
<input class="navbar-search-input js-search-input" type="search" name="q" placeholder="{{t 'search_verb'}}" | |||
maxlength="30" autocomplete="off"> | |||
<div class="js-search-results"></div> | |||
<div class="note navbar-search-subtitle">{{t 'search.shortcut'}}</div> |
@@ -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; |
@@ -31,7 +31,7 @@ export default class UserCard extends React.PureComponent { | |||
return ( | |||
<div className="account-user"> | |||
<div id="avatar" className="pull-left account-user-avatar"> | |||
<Avatar email={user.email} size={60} /> | |||
<Avatar email={user.email} name={user.name} size={60} /> | |||
</div> | |||
<h1 id="name" className="pull-left">{user.name}</h1> | |||
</div> |
@@ -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} |
@@ -95,6 +95,7 @@ export default class AssigneeFacet extends React.PureComponent { | |||
<Avatar | |||
className="little-spacer-right" | |||
hash={referencedUsers[assignee].avatar} | |||
name={referencedUsers[assignee].name} | |||
size={16} | |||
/> | |||
{referencedUsers[assignee].name} | |||
@@ -115,7 +116,12 @@ export default class AssigneeFacet extends React.PureComponent { | |||
return ( | |||
<span> | |||
{option.avatar != null && | |||
<Avatar className="little-spacer-right" hash={option.avatar} size={16} />} | |||
<Avatar | |||
className="little-spacer-right" | |||
hash={option.avatar} | |||
name={option.label} | |||
size={16} | |||
/>} | |||
{option.label} | |||
</span> | |||
); |
@@ -32,6 +32,7 @@ exports[`should render 1`] = ` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="avatart-foo" | |||
name="name-foo" | |||
size={16} | |||
/> | |||
name-foo | |||
@@ -65,6 +66,7 @@ exports[`should render footer select option 1`] = ` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="avatar-foo" | |||
name="name-foo" | |||
size={16} | |||
/> | |||
name-foo | |||
@@ -117,6 +119,7 @@ exports[`should select unassigned 1`] = ` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="avatart-foo" | |||
name="name-foo" | |||
size={16} | |||
/> | |||
name-foo | |||
@@ -177,6 +180,7 @@ exports[`should select user 1`] = ` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="avatart-foo" | |||
name="name-foo" | |||
size={16} | |||
/> | |||
name-foo |
@@ -45,7 +45,7 @@ export default class MembersListItem extends React.PureComponent { | |||
return ( | |||
<tr> | |||
<td className="thin nowrap"> | |||
<Avatar hash={member.avatar} email={member.email} size={AVATAR_SIZE} /> | |||
<Avatar hash={member.avatar} email={member.email} name={member.name} size={AVATAR_SIZE} /> | |||
</td> | |||
<td className="nowrap text-middle"> | |||
<strong>{member.name}</strong> |
@@ -7,6 +7,7 @@ exports[`should groups at 0 if the groupCount field is not defined (just added u | |||
> | |||
<Connect(Avatar) | |||
hash="7daf6c79d4802916d83f6266e24850af" | |||
name="John Doe" | |||
size={36} | |||
/> | |||
</td> | |||
@@ -101,6 +102,7 @@ exports[`should not render actions and groups for non admin 1`] = ` | |||
> | |||
<Connect(Avatar) | |||
hash="" | |||
name="Admin Istrator" | |||
size={36} | |||
/> | |||
</td> | |||
@@ -126,6 +128,7 @@ exports[`should render actions and groups for admin 1`] = ` | |||
> | |||
<Connect(Avatar) | |||
hash="" | |||
name="Admin Istrator" | |||
size={36} | |||
/> | |||
</td> |
@@ -59,7 +59,12 @@ export default class UserHolder extends React.PureComponent { | |||
<tr> | |||
<td className="nowrap"> | |||
{!isCreator && | |||
<Avatar email={user.email} size={36} className="text-middle big-spacer-right" />} | |||
<Avatar | |||
email={user.email} | |||
name={user.name} | |||
size={36} | |||
className="text-middle big-spacer-right" | |||
/>} | |||
<div className="display-inline-block text-middle"> | |||
<div> | |||
<strong>{user.name}</strong> |
@@ -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 = { |
@@ -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 { |
@@ -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() { |
@@ -63,7 +63,7 @@ export default class UsersSelectSearchOption extends React.PureComponent { | |||
onMouseMove={this.handleMouseMove} | |||
title={user.name}> | |||
<div className="little-spacer-bottom little-spacer-top"> | |||
<Avatar hash={user.avatar} email={user.email} size={AVATAR_SIZE} /> | |||
<Avatar hash={user.avatar} email={user.email} name={user.name} size={AVATAR_SIZE} /> | |||
<strong className="spacer-left">{this.props.children}</strong> | |||
<span className="note little-spacer-left">{user.login}</span> | |||
</div> |
@@ -39,7 +39,7 @@ export default class UsersSelectSearchValue extends React.PureComponent { | |||
{user && | |||
user.login && | |||
<div className="Select-value-label"> | |||
<Avatar hash={user.avatar} email={user.email} size={AVATAR_SIZE} /> | |||
<Avatar hash={user.avatar} email={user.email} name={user.name} size={AVATAR_SIZE} /> | |||
<strong className="spacer-left">{this.props.children}</strong> | |||
<span className="note little-spacer-left">{user.login}</span> | |||
</div>} |
@@ -12,6 +12,7 @@ exports[`should render correctly with email instead of hash 1`] = ` | |||
> | |||
<Connect(Avatar) | |||
email="admin@admin.ch" | |||
name="Administrator" | |||
size={20} | |||
/> | |||
<strong | |||
@@ -40,6 +41,7 @@ exports[`should render correctly without all parameters 1`] = ` | |||
> | |||
<Connect(Avatar) | |||
hash="7daf6c79d4802916d83f6266e24850af" | |||
name="Administrator" | |||
size={20} | |||
/> | |||
<strong |
@@ -10,6 +10,7 @@ exports[`should render correctly with a user 1`] = ` | |||
> | |||
<Connect(Avatar) | |||
hash="7daf6c79d4802916d83f6266e24850af" | |||
name="Administrator" | |||
size={20} | |||
/> | |||
<strong | |||
@@ -36,6 +37,7 @@ exports[`should render correctly with email instead of hash 1`] = ` | |||
> | |||
<Connect(Avatar) | |||
email="admin@admin.ch" | |||
name="Administrator" | |||
size={20} | |||
/> | |||
<strong |
@@ -1,8 +1,6 @@ | |||
{{#ifShowAvatars}} | |||
<td class="thin nowrap"> | |||
<div>{{avatarHelper email 36}}</div> | |||
</td> | |||
{{/ifShowAvatars}} | |||
<td class="thin nowrap"> | |||
<div>{{avatarHelper email name 36}}</div> | |||
</td> | |||
<td> | |||
<div> |
@@ -17,20 +17,31 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import Handlebars from 'handlebars/runtime'; | |||
// @flow | |||
import React from 'react'; | |||
import classNames from 'classnames'; | |||
function gravatarServer() { | |||
const getStore = require('../../app/utils/getStore').default; | |||
const { getSettingValue } = require('../../store/rootReducer'); | |||
type Props = { | |||
className?: string, | |||
size?: number | |||
}; | |||
const store = getStore(); | |||
return (getSettingValue(store.getState(), 'sonar.lf.gravatarServerUrl') || {}).value; | |||
export default function ClockIcon(props: Props) { | |||
/* eslint max-len: 0 */ | |||
return ( | |||
<svg | |||
className={classNames('icon-clock', props.className)} | |||
viewBox="0 0 16 16" | |||
width={props.size} | |||
height={props.size}> | |||
<g fill="#fff" stroke="#ADADAD" transform="matrix(1.4 0 0 1.4 .3 .7)"> | |||
<circle cx="5.5" cy="5.2" r="5" /> | |||
<path fillRule="nonzero" d="M5.6 2.9v2.7l2-.5" /> | |||
</g> | |||
</svg> | |||
); | |||
} | |||
module.exports = function(emailHash, size) { | |||
// double the size for high pixel density screens | |||
const url = gravatarServer().replace('{EMAIL_MD5}', emailHash).replace('{SIZE}', size * 2); | |||
return new Handlebars.default.SafeString( | |||
`<img class="rounded" src="${url}" width="${size}" height="${size}" alt="">` | |||
); | |||
ClockIcon.defaultProps = { | |||
size: 16 | |||
}; |
@@ -0,0 +1,83 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import classNames from 'classnames'; | |||
type Props = { | |||
children?: React.Element<*>, | |||
className?: string, | |||
loading?: boolean, | |||
timeout: number | |||
}; | |||
type State = { | |||
showSpinner: boolean | |||
}; | |||
export default class DeferredSpinner extends React.PureComponent { | |||
props: Props; | |||
state: State; | |||
timer: number; | |||
static defaultProps = { | |||
timeout: 100 | |||
}; | |||
constructor(props: Props) { | |||
super(props); | |||
this.state = { showSpinner: false }; | |||
} | |||
componentDidMount() { | |||
if (this.props.loading == null || this.props.loading === true) { | |||
this.startTimer(); | |||
} | |||
} | |||
componentWillReceiveProps(nextProps: Props) { | |||
if (this.props.loading === false && nextProps.loading === true) { | |||
this.stopTimer(); | |||
this.startTimer(); | |||
} | |||
if (this.props.loading === true && nextProps.loading === false) { | |||
this.stopTimer(); | |||
this.setState({ showSpinner: false }); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.stopTimer(); | |||
} | |||
startTimer = () => { | |||
this.timer = setTimeout(() => this.setState({ showSpinner: true }), this.props.timeout); | |||
}; | |||
stopTimer = () => { | |||
clearTimeout(this.timer); | |||
}; | |||
render() { | |||
return this.state.showSpinner | |||
? <i className={classNames('spinner', this.props.className)} /> | |||
: this.props.children || null; | |||
} | |||
} |
@@ -0,0 +1,44 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import classNames from 'classnames'; | |||
type Props = { | |||
className?: string, | |||
favorite: boolean, | |||
size?: number | |||
}; | |||
export default function FavoriteIcon(props: Props) { | |||
/* eslint max-len: 0 */ | |||
return ( | |||
<span | |||
className={classNames('icon-star', { 'icon-star-favorite': props.favorite }, props.className)}> | |||
<svg width={props.size} height={props.size} viewBox="0 0 16 16"> | |||
<path d="M15.4275,5.77678C15.4275,5.90773 15.3501,6.05059 15.1953,6.20536L11.9542,9.36608L12.7221,13.8304C12.728,13.872 12.731,13.9316 12.731,14.0089C12.731,14.1339 12.6998,14.2396 12.6373,14.3259C12.5748,14.4122 12.484,14.4554 12.3649,14.4554C12.2518,14.4554 12.1328,14.4197 12.0078,14.3482L7.99888,12.2411L3.98995,14.3482C3.85901,14.4197 3.73996,14.4554 3.63281,14.4554C3.50781,14.4554 3.41406,14.4122 3.35156,14.3259C3.28906,14.2396 3.25781,14.1339 3.25781,14.0089C3.25781,13.9732 3.26377,13.9137 3.27567,13.8304L4.04353,9.36608L0.793531,6.20536C0.644719,6.04464 0.570313,5.90178 0.570313,5.77678C0.570313,5.55654 0.736979,5.41964 1.07031,5.36606L5.55245,4.71428L7.56138,0.651781C7.67447,0.407729 7.8203,0.285703 7.99888,0.285703C8.17745,0.285703 8.32328,0.407729 8.43638,0.651781L10.4453,4.71428L14.9274,5.36606C15.2608,5.41964 15.4274,5.55654 15.4274,5.77678L15.4275,5.77678Z" /> | |||
</svg> | |||
</span> | |||
); | |||
} | |||
FavoriteIcon.defaultProps = { | |||
size: 16 | |||
}; |
@@ -0,0 +1,56 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2017 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
// @flow | |||
import React from 'react'; | |||
import { mount } from 'enzyme'; | |||
import DeferredSpinner from '../DeferredSpinner'; | |||
jest.useFakeTimers(); | |||
it('renders spinner after timeout', () => { | |||
const spinner = mount(<DeferredSpinner />); | |||
expect(spinner).toMatchSnapshot(); | |||
jest.runAllTimers(); | |||
expect(spinner).toMatchSnapshot(); | |||
}); | |||
it('add custom className', () => { | |||
const spinner = mount(<DeferredSpinner className="foo" />); | |||
jest.runAllTimers(); | |||
expect(spinner).toMatchSnapshot(); | |||
}); | |||
it('renders children before timeout', () => { | |||
const spinner = mount(<DeferredSpinner><div>foo</div></DeferredSpinner>); | |||
expect(spinner).toMatchSnapshot(); | |||
jest.runAllTimers(); | |||
expect(spinner).toMatchSnapshot(); | |||
}); | |||
it('is controlled by loading prop', () => { | |||
const spinner = mount(<DeferredSpinner loading={false}><div>foo</div></DeferredSpinner>); | |||
expect(spinner).toMatchSnapshot(); | |||
spinner.setProps({ loading: true }); | |||
expect(spinner).toMatchSnapshot(); | |||
jest.runAllTimers(); | |||
expect(spinner).toMatchSnapshot(); | |||
spinner.setProps({ loading: false }); | |||
expect(spinner).toMatchSnapshot(); | |||
}); |
@@ -0,0 +1,92 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`add custom className 1`] = ` | |||
<DeferredSpinner | |||
className="foo" | |||
timeout={100} | |||
> | |||
<i | |||
className="spinner foo" | |||
/> | |||
</DeferredSpinner> | |||
`; | |||
exports[`is controlled by loading prop 1`] = ` | |||
<DeferredSpinner | |||
loading={false} | |||
timeout={100} | |||
> | |||
<div> | |||
foo | |||
</div> | |||
</DeferredSpinner> | |||
`; | |||
exports[`is controlled by loading prop 2`] = ` | |||
<DeferredSpinner | |||
loading={true} | |||
timeout={100} | |||
> | |||
<div> | |||
foo | |||
</div> | |||
</DeferredSpinner> | |||
`; | |||
exports[`is controlled by loading prop 3`] = ` | |||
<DeferredSpinner | |||
loading={true} | |||
timeout={100} | |||
> | |||
<i | |||
className="spinner" | |||
/> | |||
</DeferredSpinner> | |||
`; | |||
exports[`is controlled by loading prop 4`] = ` | |||
<DeferredSpinner | |||
loading={false} | |||
timeout={100} | |||
> | |||
<div> | |||
foo | |||
</div> | |||
</DeferredSpinner> | |||
`; | |||
exports[`renders children before timeout 1`] = ` | |||
<DeferredSpinner | |||
timeout={100} | |||
> | |||
<div> | |||
foo | |||
</div> | |||
</DeferredSpinner> | |||
`; | |||
exports[`renders children before timeout 2`] = ` | |||
<DeferredSpinner | |||
timeout={100} | |||
> | |||
<i | |||
className="spinner" | |||
/> | |||
</DeferredSpinner> | |||
`; | |||
exports[`renders spinner after timeout 1`] = ` | |||
<DeferredSpinner | |||
timeout={100} | |||
/> | |||
`; | |||
exports[`renders spinner after timeout 2`] = ` | |||
<DeferredSpinner | |||
timeout={100} | |||
> | |||
<i | |||
className="spinner" | |||
/> | |||
</DeferredSpinner> | |||
`; |
@@ -19,6 +19,7 @@ | |||
*/ | |||
import React from 'react'; | |||
import classNames from 'classnames'; | |||
import FavoriteIcon from '../common/FavoriteIcon'; | |||
export default class FavoriteBase extends React.PureComponent { | |||
static propTypes = { | |||
@@ -67,27 +68,13 @@ export default class FavoriteBase extends React.PureComponent { | |||
}); | |||
} | |||
renderSVG() { | |||
/* eslint max-len: 0 */ | |||
return ( | |||
<svg width="16" height="16"> | |||
<path d="M15.4275,5.77678C15.4275,5.90773 15.3501,6.05059 15.1953,6.20536L11.9542,9.36608L12.7221,13.8304C12.728,13.872 12.731,13.9316 12.731,14.0089C12.731,14.1339 12.6998,14.2396 12.6373,14.3259C12.5748,14.4122 12.484,14.4554 12.3649,14.4554C12.2518,14.4554 12.1328,14.4197 12.0078,14.3482L7.99888,12.2411L3.98995,14.3482C3.85901,14.4197 3.73996,14.4554 3.63281,14.4554C3.50781,14.4554 3.41406,14.4122 3.35156,14.3259C3.28906,14.2396 3.25781,14.1339 3.25781,14.0089C3.25781,13.9732 3.26377,13.9137 3.27567,13.8304L4.04353,9.36608L0.793531,6.20536C0.644719,6.04464 0.570313,5.90178 0.570313,5.77678C0.570313,5.55654 0.736979,5.41964 1.07031,5.36606L5.55245,4.71428L7.56138,0.651781C7.67447,0.407729 7.8203,0.285703 7.99888,0.285703C8.17745,0.285703 8.32328,0.407729 8.43638,0.651781L10.4453,4.71428L14.9274,5.36606C15.2608,5.41964 15.4274,5.55654 15.4274,5.77678L15.4275,5.77678Z" /> | |||
</svg> | |||
); | |||
} | |||
render() { | |||
const className = classNames( | |||
'icon-star', | |||
{ | |||
'icon-star-favorite': this.state.favorite | |||
}, | |||
this.props.className | |||
); | |||
return ( | |||
<a className={className} href="#" onClick={this.toggleFavorite}> | |||
{this.renderSVG()} | |||
<a | |||
className={classNames('link-no-underline', this.props.className)} | |||
href="#" | |||
onClick={this.toggleFavorite}> | |||
<FavoriteIcon favorite={this.state.favorite} /> | |||
</a> | |||
); | |||
} |
@@ -19,6 +19,7 @@ | |||
*/ | |||
import React from 'react'; | |||
import classNames from 'classnames'; | |||
import FavoriteIcon from '../common/FavoriteIcon'; | |||
export default class FavoriteBaseStateless extends React.PureComponent { | |||
static propTypes = { | |||
@@ -37,27 +38,13 @@ export default class FavoriteBaseStateless extends React.PureComponent { | |||
} | |||
}; | |||
renderSVG() { | |||
/* eslint max-len: 0 */ | |||
return ( | |||
<svg width="16" height="16"> | |||
<path d="M15.4275,5.77678C15.4275,5.90773 15.3501,6.05059 15.1953,6.20536L11.9542,9.36608L12.7221,13.8304C12.728,13.872 12.731,13.9316 12.731,14.0089C12.731,14.1339 12.6998,14.2396 12.6373,14.3259C12.5748,14.4122 12.484,14.4554 12.3649,14.4554C12.2518,14.4554 12.1328,14.4197 12.0078,14.3482L7.99888,12.2411L3.98995,14.3482C3.85901,14.4197 3.73996,14.4554 3.63281,14.4554C3.50781,14.4554 3.41406,14.4122 3.35156,14.3259C3.28906,14.2396 3.25781,14.1339 3.25781,14.0089C3.25781,13.9732 3.26377,13.9137 3.27567,13.8304L4.04353,9.36608L0.793531,6.20536C0.644719,6.04464 0.570313,5.90178 0.570313,5.77678C0.570313,5.55654 0.736979,5.41964 1.07031,5.36606L5.55245,4.71428L7.56138,0.651781C7.67447,0.407729 7.8203,0.285703 7.99888,0.285703C8.17745,0.285703 8.32328,0.407729 8.43638,0.651781L10.4453,4.71428L14.9274,5.36606C15.2608,5.41964 15.4274,5.55654 15.4274,5.77678L15.4275,5.77678Z" /> | |||
</svg> | |||
); | |||
} | |||
render() { | |||
const className = classNames( | |||
'icon-star', | |||
{ | |||
'icon-star-favorite': this.props.favorite | |||
}, | |||
this.props.className | |||
); | |||
return ( | |||
<a className={className} href="#" onClick={this.toggleFavorite}> | |||
{this.renderSVG()} | |||
<a | |||
className={classNames('link-no-underline', this.props.className)} | |||
href="#" | |||
onClick={this.toggleFavorite}> | |||
<FavoriteIcon favorite={this.props.favorite} /> | |||
</a> | |||
); | |||
} |
@@ -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', () => { |
@@ -0,0 +1,27 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render favorite 1`] = ` | |||
<a | |||
className="link-no-underline" | |||
href="#" | |||
onClick={[Function]} | |||
> | |||
<FavoriteIcon | |||
favorite={true} | |||
size={16} | |||
/> | |||
</a> | |||
`; | |||
exports[`should render not favorite 1`] = ` | |||
<a | |||
className="link-no-underline" | |||
href="#" | |||
onClick={[Function]} | |||
> | |||
<FavoriteIcon | |||
favorite={false} | |||
size={16} | |||
/> | |||
</a> | |||
`; |
@@ -47,7 +47,12 @@ export default class IssueAssign extends React.PureComponent { | |||
<span> | |||
{issue.assignee && | |||
<span className="text-top"> | |||
<Avatar className="little-spacer-right" hash={issue.assigneeAvatar} size={16} /> | |||
<Avatar | |||
className="little-spacer-right" | |||
hash={issue.assigneeAvatar} | |||
name={issue.assigneeName} | |||
size={16} | |||
/> | |||
</span>} | |||
<span className="issue-meta-label"> | |||
{issue.assignee ? issue.assigneeName : translate('unassigned')} |
@@ -72,7 +72,12 @@ export default class IssueCommentLine extends React.PureComponent { | |||
return ( | |||
<div className="issue-comment"> | |||
<div className="issue-comment-author" title={comment.authorName}> | |||
<Avatar className="little-spacer-right" hash={comment.authorAvatar} size={16} /> | |||
<Avatar | |||
className="little-spacer-right" | |||
hash={comment.authorAvatar} | |||
name={comment.authorName} | |||
size={16} | |||
/> | |||
{comment.authorName} | |||
</div> | |||
<div |
@@ -48,6 +48,7 @@ exports[`should open the popup when the button is clicked 2`] = ` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="gravatarhash" | |||
name="John Doe" | |||
size={16} | |||
/> | |||
</span> | |||
@@ -94,6 +95,7 @@ exports[`should render with the action 1`] = ` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="gravatarhash" | |||
name="John Doe" | |||
size={16} | |||
/> | |||
</span> | |||
@@ -118,6 +120,7 @@ exports[`should render without the action when the correct rights are missing 1` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="gravatarhash" | |||
name="John Doe" | |||
size={16} | |||
/> | |||
</span> |
@@ -23,6 +23,7 @@ exports[`should open the right popups when the buttons are clicked 3`] = ` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="gravatarhash" | |||
name="John Doe" | |||
size={16} | |||
/> | |||
John Doe | |||
@@ -117,6 +118,7 @@ exports[`should render correctly a comment that is not updatable 1`] = ` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="gravatarhash" | |||
name="John Doe" | |||
size={16} | |||
/> | |||
John Doe | |||
@@ -153,6 +155,7 @@ exports[`should render correctly a comment that is updatable 1`] = ` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="gravatarhash" | |||
name="John Doe" | |||
size={16} | |||
/> | |||
John Doe |
@@ -94,9 +94,12 @@ export default class ChangelogPopup extends React.PureComponent { | |||
{moment(item.creationDate).format('LLL')} | |||
</td> | |||
<td className="thin text-left text-top nowrap"> | |||
{item.userName && | |||
item.avatar && | |||
<Avatar className="little-spacer-right" hash={item.avatar} size={16} />} | |||
<Avatar | |||
className="little-spacer-right" | |||
hash={item.avatar} | |||
name={item.userName} | |||
size={16} | |||
/> | |||
{item.userName} | |||
</td> | |||
<td className="text-left text-top"> |
@@ -141,13 +141,13 @@ export default class SetAssigneePopup extends React.PureComponent { | |||
onSelect={this.props.onSelect}> | |||
{this.state.users.map(user => ( | |||
<SelectListItem key={user.login} item={user.login}> | |||
{(user.avatar || user.email) && | |||
<Avatar | |||
className="spacer-right" | |||
email={user.email} | |||
hash={user.avatar} | |||
size={16} | |||
/>} | |||
<Avatar | |||
className="spacer-right" | |||
email={user.email} | |||
hash={user.avatar} | |||
name={user.name} | |||
size={16} | |||
/> | |||
<span | |||
className="vertical-middle" | |||
style={{ marginLeft: !user.avatar && !user.email ? 24 : undefined }}> |
@@ -96,6 +96,7 @@ export default class SimilarIssuesPopup extends React.PureComponent { | |||
<Avatar | |||
className="little-spacer-left little-spacer-right" | |||
hash={issue.assigneeAvatar} | |||
name={issue.assigneeName} | |||
size={16} | |||
/> | |||
{issue.assigneeName} |
@@ -38,6 +38,7 @@ exports[`should render the changelog popup correctly 1`] = ` | |||
<Connect(Avatar) | |||
className="little-spacer-right" | |||
hash="gravatarhash" | |||
name="john.doe" | |||
size={16} | |||
/> | |||
john.doe |
@@ -23,19 +23,74 @@ import md5 from 'blueimp-md5'; | |||
import classNames from 'classnames'; | |||
import { getSettingValue } from '../../store/rootReducer'; | |||
function stringToColor(str) { | |||
let hash = 0; | |||
for (let i = 0; i < str.length; i++) { | |||
hash = str.charCodeAt(i) + ((hash << 5) - hash); | |||
} | |||
let color = '#'; | |||
for (let i = 0; i < 3; i++) { | |||
const value = (hash >> (i * 8)) & 0xff; | |||
color += ('00' + value.toString(16)).substr(-2); | |||
} | |||
return color; | |||
} | |||
function getTextColor(background) { | |||
const rgb = parseInt(background.substr(1), 16); | |||
const r = (rgb >> 16) & 0xff; | |||
const g = (rgb >> 8) & 0xff; | |||
const b = (rgb >> 0) & 0xff; | |||
const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; | |||
return luma > 140 ? '#222' : '#fff'; | |||
} | |||
class Avatar extends React.PureComponent { | |||
static propTypes = { | |||
enableGravatar: React.PropTypes.bool.isRequired, | |||
gravatarServerUrl: React.PropTypes.string.isRequired, | |||
email: React.PropTypes.string, | |||
hash: React.PropTypes.string, | |||
name: React.PropTypes.string.isRequired, | |||
size: React.PropTypes.number.isRequired, | |||
className: React.PropTypes.string | |||
}; | |||
renderFallback() { | |||
const className = classNames(this.props.className, 'rounded'); | |||
const color = stringToColor(this.props.name); | |||
let text = ''; | |||
const words = this.props.name.split(/\s+/).filter(word => word.length > 0); | |||
if (words.length >= 2) { | |||
text = words[0][0] + words[1][0]; | |||
} else if (this.props.name.length > 0) { | |||
text = this.props.name[0]; | |||
} | |||
return ( | |||
<div | |||
className={className} | |||
style={{ | |||
backgroundColor: color, | |||
color: getTextColor(color), | |||
display: 'inline-block', | |||
fontSize: Math.min(this.props.size / 2, 14), | |||
fontWeight: 'normal', | |||
height: this.props.size, | |||
lineHeight: `${this.props.size}px`, | |||
textAlign: 'center', | |||
verticalAlign: 'top', | |||
width: this.props.size | |||
}}> | |||
{text.toUpperCase()} | |||
</div> | |||
); | |||
} | |||
render() { | |||
if (!this.props.enableGravatar) { | |||
return null; | |||
return this.renderFallback(); | |||
} | |||
const emailHash = this.props.hash || md5.md5((this.props.email || '').toLowerCase()).trim(); |
@@ -29,26 +29,11 @@ it('should render', () => { | |||
enableGravatar={true} | |||
gravatarServerUrl={gravatarServerUrl} | |||
email="mail@example.com" | |||
name="Foo" | |||
size={20} | |||
/> | |||
); | |||
expect(avatar.is('img')).toBe(true); | |||
expect(avatar.prop('width')).toBe(20); | |||
expect(avatar.prop('height')).toBe(20); | |||
expect(avatar.prop('alt')).toBe('mail@example.com'); | |||
expect(avatar.prop('src')).toBe('http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=40'); | |||
}); | |||
it('should not render', () => { | |||
const avatar = shallow( | |||
<Avatar | |||
enableGravatar={false} | |||
gravatarServerUrl={gravatarServerUrl} | |||
email="mail@example.com" | |||
size={20} | |||
/> | |||
); | |||
expect(avatar.is('img')).toBe(false); | |||
expect(avatar).toMatchSnapshot(); | |||
}); | |||
it('should be able to render with hash only', () => { | |||
@@ -57,12 +42,16 @@ it('should be able to render with hash only', () => { | |||
enableGravatar={true} | |||
gravatarServerUrl={gravatarServerUrl} | |||
hash="7daf6c79d4802916d83f6266e24850af" | |||
name="Foo" | |||
size={30} | |||
/> | |||
); | |||
expect(avatar.is('img')).toBe(true); | |||
expect(avatar.prop('width')).toBe(30); | |||
expect(avatar.prop('height')).toBe(30); | |||
expect(avatar.prop('alt')).toBeUndefined(); | |||
expect(avatar.prop('src')).toBe('http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=60'); | |||
expect(avatar).toMatchSnapshot(); | |||
}); | |||
it('falls back to dummy avatar', () => { | |||
const avatar = shallow( | |||
<Avatar enableGravatar={false} gravatarServerUrl="" name="Foo Bar" size={30} /> | |||
); | |||
expect(avatar).toMatchSnapshot(); | |||
}); |
@@ -0,0 +1,42 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`falls back to dummy avatar 1`] = ` | |||
<div | |||
className="rounded" | |||
style={ | |||
Object { | |||
"backgroundColor": "#79e189", | |||
"color": "#222", | |||
"display": "inline-block", | |||
"fontSize": 14, | |||
"fontWeight": "normal", | |||
"height": 30, | |||
"lineHeight": "30px", | |||
"textAlign": "center", | |||
"verticalAlign": "top", | |||
"width": 30, | |||
} | |||
} | |||
> | |||
FB | |||
</div> | |||
`; | |||
exports[`should be able to render with hash only 1`] = ` | |||
<img | |||
className="rounded" | |||
height={30} | |||
src="http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=60" | |||
width={30} | |||
/> | |||
`; | |||
exports[`should render 1`] = ` | |||
<img | |||
alt="mail@example.com" | |||
className="rounded" | |||
height={20} | |||
src="http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=40" | |||
width={20} | |||
/> | |||
`; |
@@ -17,22 +17,18 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import md5 from 'blueimp-md5'; | |||
import React from 'react'; | |||
import { renderToString } from 'react-dom/server'; | |||
import Handlebars from 'handlebars/runtime'; | |||
import WithStore from '../../components/shared/WithStore'; | |||
import Avatar from '../../components/ui/Avatar'; | |||
function gravatarServer() { | |||
const getStore = require('../../app/utils/getStore').default; | |||
const { getSettingValue } = require('../../store/rootReducer'); | |||
const store = getStore(); | |||
return (getSettingValue(store.getState(), 'sonar.lf.gravatarServerUrl') || {}).value; | |||
} | |||
module.exports = function(email, size) { | |||
// double the size for high pixel density screens | |||
const emailHash = md5.md5((email || '').trim()); | |||
const url = gravatarServer().replace('{EMAIL_MD5}', emailHash).replace('{SIZE}', size * 2); | |||
module.exports = function(email, name, size) { | |||
return new Handlebars.default.SafeString( | |||
`<img class="rounded" src="${url}" width="${size}" height="${size}" alt="${email}">` | |||
renderToString( | |||
<WithStore> | |||
<Avatar email={email} name={name} size={size} /> | |||
</WithStore> | |||
) | |||
); | |||
}; |
@@ -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() {} | |||
}); | |||
}; |
@@ -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 { |
@@ -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; | |||
} |
@@ -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 */ ; |
@@ -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'); } | |||
@@ -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); } |
@@ -588,6 +588,7 @@ portfolios.page=Portfolios | |||
project_activity.page=Activity | |||
project_activity.page.description=The page shows the history of project analyses. | |||
#------------------------------------------------------------------------------ | |||
# | |||
# ASYNC PROCESS | |||
@@ -1013,7 +1014,8 @@ property.category.scm=SCM | |||
#------------------------------------------------------------------------------ | |||
search.results=results | |||
search.duration=({0} seconds) | |||
search.shortcut=Press S to quickly open search bar | |||
search.shortcut_hint=Hint: Press <span class="shortcut-button">{0}</span> from anywhere to open this search bar. | |||
search.placeholder=Search for projects, modules and files... | |||
#------------------------------------------------------------------------------ |