@@ -192,11 +192,14 @@ export type SuggestionsResponse = { | |||
}; | |||
export const getSuggestions = ( | |||
query: string, | |||
query?: string, | |||
recentlyBrowsed?: Array<string>, | |||
more?: string | |||
): Promise<SuggestionsResponse> => { | |||
const data: Object = { s: query }; | |||
const data: Object = {}; | |||
if (query) { | |||
data.s = query; | |||
} | |||
if (recentlyBrowsed) { | |||
data.recentlyBrowsed = recentlyBrowsed.join(); | |||
} |
@@ -22,7 +22,7 @@ import { connect } from 'react-redux'; | |||
import GlobalNavBranding from './GlobalNavBranding'; | |||
import GlobalNavMenu from './GlobalNavMenu'; | |||
import GlobalNavUser from './GlobalNavUser'; | |||
import GlobalNavSearchForm from './GlobalNavSearchForm'; | |||
import Search from '../../search/Search'; | |||
import ShortcutsHelpView from './ShortcutsHelpView'; | |||
import { getCurrentUser, getAppState } from '../../../../store/rootReducer'; | |||
@@ -63,7 +63,7 @@ class GlobalNav extends React.PureComponent { | |||
<GlobalNavMenu {...this.props} /> | |||
<ul className="nav navbar-nav navbar-right"> | |||
<GlobalNavSearchForm {...this.props} /> | |||
<Search {...this.props} /> | |||
<li> | |||
<a className="navbar-help" onClick={this.openHelp} href="#"> | |||
<svg width="16" height="16"> |
@@ -58,6 +58,16 @@ class GlobalNavUser extends React.PureComponent { | |||
<Avatar email={currentUser.email} name={currentUser.name} size={24} /> | |||
</a> | |||
<ul className="dropdown-menu dropdown-menu-right"> | |||
<li className="dropdown-item"> | |||
<div className="text-ellipsis text-muted" title={currentUser.name}> | |||
<strong>{currentUser.name}</strong> | |||
</div> | |||
{currentUser.email != null && | |||
<div className="little-spacer-top text-ellipsis text-muted" title={currentUser.email}> | |||
{currentUser.email} | |||
</div>} | |||
</li> | |||
<li className="divider" /> | |||
<li> | |||
<Link to="/account">{translate('my_account.page')}</Link> | |||
</li> |
@@ -1,262 +0,0 @@ | |||
// 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> | |||
`; |
@@ -21,16 +21,17 @@ | |||
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'; | |||
import { debounce, keyBy, uniqBy } from 'lodash'; | |||
import SearchResults from './SearchResults'; | |||
import SearchResult from './SearchResult'; | |||
import { sortQualifiers } from './utils'; | |||
import type { Component, More, Results } from './utils'; | |||
import RecentHistory from '../../components/RecentHistory'; | |||
import DeferredSpinner from '../../../components/common/DeferredSpinner'; | |||
import { getSuggestions } from '../../../api/components'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
import { scrollToElement } from '../../../helpers/scrolling'; | |||
import { getProjectUrl } from '../../../helpers/urls'; | |||
type Props = {| | |||
appState: { organizationsEnabled: boolean }, | |||
@@ -40,19 +41,17 @@ type Props = {| | |||
type State = { | |||
loading: boolean, | |||
loadingMore: ?string, | |||
more: { [string]: number }, | |||
more: More, | |||
open: boolean, | |||
organizations: { [string]: { name: string } }, | |||
projects: { [string]: { name: string } }, | |||
query: string, | |||
results: { [qualifier: string]: Array<Component> }, | |||
results: Results, | |||
selected: ?string, | |||
shortQuery: boolean | |||
}; | |||
const ORDER = ['DEV', 'VW', 'SVW', 'TRK', 'BRC', 'FIL', 'UTS']; | |||
export default class GlobalNavSearchForm extends React.PureComponent { | |||
export default class Search extends React.PureComponent { | |||
input: HTMLElement; | |||
mounted: boolean; | |||
node: HTMLElement; | |||
@@ -68,9 +67,6 @@ export default class GlobalNavSearchForm extends React.PureComponent { | |||
super(props); | |||
this.nodes = {}; | |||
this.search = debounce(this.search, 250); | |||
this.fetchFavoritesAndRecentlyBrowsed = debounce(this.fetchFavoritesAndRecentlyBrowsed, 250, { | |||
leading: true | |||
}); | |||
this.state = { | |||
loading: false, | |||
loadingMore: null, | |||
@@ -92,7 +88,6 @@ export default class GlobalNavSearchForm extends React.PureComponent { | |||
this.openSearch(); | |||
return false; | |||
}); | |||
this.fetchFavoritesAndRecentlyBrowsed(); | |||
} | |||
componentWillUpdate() { | |||
@@ -113,40 +108,49 @@ export default class GlobalNavSearchForm extends React.PureComponent { | |||
handleClickOutside = (event: { target: HTMLElement }) => { | |||
if (!this.node || !this.node.contains(event.target)) { | |||
this.closeSearch(); | |||
this.closeSearch(false); | |||
} | |||
}; | |||
openSearch = () => { | |||
window.addEventListener('click', this.handleClickOutside); | |||
if (!this.state.open) { | |||
this.fetchFavoritesAndRecentlyBrowsed(); | |||
if (!this.state.open && !this.state.query) { | |||
this.search(''); | |||
} | |||
this.setState({ open: true }); | |||
}; | |||
closeSearch = () => { | |||
closeSearch = (clear: boolean = true) => { | |||
if (this.input) { | |||
this.input.blur(); | |||
} | |||
window.removeEventListener('click', this.handleClickOutside); | |||
this.setState({ | |||
more: {}, | |||
open: false, | |||
organizations: {}, | |||
projects: {}, | |||
query: '', | |||
results: {}, | |||
selected: null, | |||
shortQuery: false | |||
}); | |||
this.setState( | |||
clear | |||
? { | |||
more: {}, | |||
open: false, | |||
organizations: {}, | |||
projects: {}, | |||
query: '', | |||
results: {}, | |||
selected: null, | |||
shortQuery: false | |||
} | |||
: { | |||
open: false | |||
} | |||
); | |||
}; | |||
getPlainComponentsList = (results: { [qualifier: string]: Array<Component> }): Array<Component> => | |||
this.sortQualifiers(Object.keys(results)).reduce( | |||
(components, qualifier) => [...components, ...results[qualifier]], | |||
[] | |||
); | |||
getPlainComponentsList = (results: Results, more: More): Array<string> => | |||
sortQualifiers(Object.keys(results)).reduce((components, qualifier) => { | |||
const next = [...components, ...results[qualifier].map(component => component.key)]; | |||
if (more[qualifier]) { | |||
next.push('qualifier###' + qualifier); | |||
} | |||
return next; | |||
}, []); | |||
mergeWithRecentlyBrowsed = (components: Array<Component>) => { | |||
const recentlyBrowsed = RecentHistory.get().map(component => ({ | |||
@@ -157,49 +161,27 @@ export default class GlobalNavSearchForm extends React.PureComponent { | |||
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) { | |||
// compare `this.state.query` and `query` to handle two request done almost at the same time | |||
// in this case only the request that matches the current query should be taken | |||
if (this.mounted && this.state.query === query) { | |||
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); | |||
const list = this.getPlainComponentsList(results, more); | |||
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, | |||
selected: list.length > 0 ? list[0] : null, | |||
shortQuery: response.warning === 'short_input' | |||
})); | |||
} | |||
@@ -207,57 +189,67 @@ export default class GlobalNavSearchForm extends React.PureComponent { | |||
}; | |||
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') | |||
} | |||
})); | |||
} | |||
}); | |||
if (this.state.query.length !== 1) { | |||
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') | |||
}, | |||
selected: moreResults.length > 0 ? moreResults[0].key : state.selected | |||
})); | |||
} | |||
}); | |||
} | |||
}; | |||
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) { | |||
if (query.length === 0 || 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; | |||
this.setState(({ more, results, selected }: State) => { | |||
if (selected) { | |||
const list = this.getPlainComponentsList(results, more); | |||
const index = list.indexOf(selected); | |||
return index > 0 ? { selected: list[index - 1] } : 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; | |||
this.setState(({ more, results, selected }: State) => { | |||
if (selected) { | |||
const list = this.getPlainComponentsList(results, more); | |||
const index = list.indexOf(selected); | |||
return index >= 0 && index < list.length - 1 ? { selected: list[index + 1] } : undefined; | |||
} | |||
}); | |||
}; | |||
openSelected = () => { | |||
if (this.state.selected) { | |||
this.context.router.push(getProjectUrl(this.state.selected)); | |||
this.closeSearch(); | |||
const { selected } = this.state; | |||
if (selected) { | |||
if (selected.startsWith('qualifier###')) { | |||
this.searchMore(selected.substr(12)); | |||
} else { | |||
this.context.router.push(getProjectUrl(selected)); | |||
this.closeSearch(); | |||
} | |||
} | |||
}; | |||
@@ -295,23 +287,12 @@ export default class GlobalNavSearchForm extends React.PureComponent { | |||
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 | |||
renderResult = (component: Component) => ( | |||
<SearchResult | |||
appState={this.props.appState} | |||
component={component} | |||
innerRef={this.innerRef} | |||
@@ -324,47 +305,11 @@ export default class GlobalNavSearchForm extends React.PureComponent { | |||
/> | |||
); | |||
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; | |||
}; | |||
renderNoResults = () => ( | |||
<div className="navbar-search-no-results"> | |||
{translateWithParameters('no_results_for_x', this.state.query)} | |||
</div> | |||
); | |||
render() { | |||
const dropdownClassName = classNames('dropdown', 'navbar-search', { open: this.state.open }); | |||
@@ -403,13 +348,24 @@ export default class GlobalNavSearchForm extends React.PureComponent { | |||
<div | |||
className="dropdown-menu dropdown-menu-right global-navbar-search-dropdown" | |||
ref={node => (this.node = node)}> | |||
<ul className="menu"> | |||
{this.renderComponents()} | |||
</ul> | |||
<SearchResults | |||
allowMore={this.state.query.length !== 1} | |||
loadingMore={this.state.loadingMore} | |||
more={this.state.more} | |||
onMoreClick={this.searchMore} | |||
onSelect={this.handleSelect} | |||
renderNoResults={this.renderNoResults} | |||
renderResult={this.renderResult} | |||
results={this.state.results} | |||
selected={this.state.selected} | |||
/> | |||
<div | |||
className="navbar-search-shortcut-hint" | |||
dangerouslySetInnerHTML={{ | |||
__html: translateWithParameters('search.shortcut_hint', 's') | |||
__html: translateWithParameters( | |||
'search.shortcut_hint', | |||
'<span class="shortcut-button shortcut-button-small">s</span>' | |||
) | |||
}} | |||
/> | |||
</div>} |
@@ -20,22 +20,12 @@ | |||
// @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 | |||
}; | |||
import type { Component } from './utils'; | |||
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'; | |||
type Props = {| | |||
appState: { organizationsEnabled: boolean }, | |||
@@ -48,7 +38,7 @@ type Props = {| | |||
selected: boolean | |||
|}; | |||
export default class GlobalNavSearchFormComponent extends React.PureComponent { | |||
export default class SearchResult extends React.PureComponent { | |||
props: Props; | |||
handleMouseEnter = () => { | |||
@@ -65,7 +55,9 @@ export default class GlobalNavSearchFormComponent extends React.PureComponent { | |||
} | |||
const organization = this.props.organizations[component.organization]; | |||
return organization ? <div className="pull-right text-muted-2">{organization.name}</div> : null; | |||
return organization | |||
? <div className="navbar-search-item-right text-muted-2">{organization.name}</div> | |||
: null; | |||
}; | |||
renderProject = (component: Component) => { | |||
@@ -74,7 +66,9 @@ export default class GlobalNavSearchFormComponent extends React.PureComponent { | |||
} | |||
const project = this.props.projects[component.project]; | |||
return project ? <div className="pull-right text-muted-2">{project.name}</div> : null; | |||
return project | |||
? <div className="navbar-search-item-right text-muted-2">{project.name}</div> | |||
: null; | |||
}; | |||
render() { | |||
@@ -87,14 +81,12 @@ export default class GlobalNavSearchFormComponent extends React.PureComponent { | |||
ref={node => this.props.innerRef(component.key, node)}> | |||
<Tooltip mouseEnterDelay={1.0} overlay={component.key} placement="left"> | |||
<Link | |||
className="navbar-search-item-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} />} | |||
@@ -102,8 +94,14 @@ export default class GlobalNavSearchFormComponent extends React.PureComponent { | |||
</span> | |||
{component.match | |||
? <span dangerouslySetInnerHTML={{ __html: component.match }} /> | |||
: component.name} | |||
? <span | |||
className="navbar-search-item-match" | |||
dangerouslySetInnerHTML={{ __html: component.match }} | |||
/> | |||
: <span className="navbar-search-item-match">{component.name}</span>} | |||
{this.renderOrganization(component)} | |||
{this.renderProject(component)} | |||
</Link> | |||
</Tooltip> |
@@ -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 SearchShowMore from './SearchShowMore'; | |||
import { sortQualifiers } from './utils'; | |||
import type { Component, More, Results } from './utils'; | |||
import { translate } from '../../../helpers/l10n'; | |||
type Props = {| | |||
allowMore: boolean, | |||
loadingMore: ?string, | |||
more: More, | |||
onMoreClick: string => void, | |||
onSelect: string => void, | |||
renderNoResults: () => React.Element<*>, | |||
renderResult: Component => React.Element<*>, | |||
results: Results, | |||
selected: ?string | |||
|}; | |||
export default class SearchResults extends React.PureComponent { | |||
props: Props; | |||
render() { | |||
const qualifiers = Object.keys(this.props.results); | |||
const renderedComponents = []; | |||
sortQualifiers(qualifiers).forEach(qualifier => { | |||
const components = this.props.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.props.renderResult(component))); | |||
const more = this.props.more[qualifier]; | |||
if (more != null && more > 0) { | |||
renderedComponents.push( | |||
<SearchShowMore | |||
allowMore={this.props.allowMore} | |||
key={`more-${qualifier}`} | |||
loadingMore={this.props.loadingMore} | |||
onMoreClick={this.props.onMoreClick} | |||
onSelect={this.props.onSelect} | |||
qualifier={qualifier} | |||
selected={this.props.selected === `qualifier###${qualifier}`} | |||
/> | |||
); | |||
} | |||
}); | |||
return renderedComponents.length > 0 | |||
? <ul className="menu">{renderedComponents}</ul> | |||
: this.props.renderNoResults(); | |||
} | |||
} |
@@ -0,0 +1,78 @@ | |||
/* | |||
* 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 DeferredSpinner from '../../../components/common/DeferredSpinner'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
type Props = {| | |||
allowMore: boolean, | |||
loadingMore: ?string, | |||
onMoreClick: string => void, | |||
onSelect: string => void, | |||
qualifier: string, | |||
selected: boolean | |||
|}; | |||
export default class SearchShowMore extends React.PureComponent { | |||
props: Props; | |||
handleMoreClick = (event: MouseEvent & { currentTarget: HTMLElement }) => { | |||
event.preventDefault(); | |||
event.stopPropagation(); | |||
event.currentTarget.blur(); | |||
const { qualifier } = event.currentTarget.dataset; | |||
this.props.onMoreClick(qualifier); | |||
}; | |||
handleMoreMouseEnter = (event: { currentTarget: HTMLElement }) => { | |||
const { qualifier } = event.currentTarget.dataset; | |||
this.props.onSelect(`qualifier###${qualifier}`); | |||
}; | |||
render() { | |||
const { loadingMore, qualifier, selected } = this.props; | |||
return ( | |||
<li key={`more-${qualifier}`} className={classNames('menu-footer', { active: selected })}> | |||
<DeferredSpinner className="navbar-search-icon" loading={loadingMore === qualifier}> | |||
<a | |||
className={classNames({ 'cursor-not-allowed': !this.props.allowMore })} | |||
data-qualifier={qualifier} | |||
href="#" | |||
onClick={this.handleMoreClick} | |||
onMouseEnter={this.handleMoreMouseEnter}> | |||
<div | |||
className="pull-right text-muted-2 menu-footer-note" | |||
dangerouslySetInnerHTML={{ | |||
__html: translateWithParameters( | |||
'search.show_more.hint', | |||
'<span class="shortcut-button shortcut-button-small">Enter</span>' | |||
) | |||
}} | |||
/> | |||
<span>{translate('show_more')}</span> | |||
</a> | |||
</DeferredSpinner> | |||
</li> | |||
); | |||
} | |||
} |
@@ -20,12 +20,12 @@ | |||
import React from 'react'; | |||
import { shallow, mount } from 'enzyme'; | |||
import type { ShallowWrapper } from 'enzyme'; | |||
import GlobalNavSearchForm from '../GlobalNavSearchForm'; | |||
import { elementKeydown, clickOutside } from '../../../../../helpers/testUtils'; | |||
import Search from '../Search'; | |||
import { elementKeydown, clickOutside } from '../../../../helpers/testUtils'; | |||
function render(props?: Object) { | |||
return shallow( | |||
<GlobalNavSearchForm | |||
<Search | |||
appState={{ organizationsEnabled: false }} | |||
currentUser={{ isLoggedIn: false }} | |||
{...props} | |||
@@ -52,35 +52,10 @@ function select(form: ShallowWrapper, expected: string) { | |||
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({ | |||
more: { TRK: 15, BRC: 0 }, | |||
open: true, | |||
results: { | |||
TRK: [component('foo'), component('bar')], | |||
@@ -90,8 +65,10 @@ it('selects results', () => { | |||
}); | |||
expect(form.state().selected).toBe('foo'); | |||
next(form, 'bar'); | |||
next(form, 'qualifier###TRK'); | |||
next(form, 'qwe'); | |||
next(form, 'qwe'); | |||
prev(form, 'qualifier###TRK'); | |||
prev(form, 'bar'); | |||
select(form, 'foo'); | |||
prev(form, 'foo'); | |||
@@ -128,10 +105,7 @@ it('closes on escape', () => { | |||
it('closes on click outside', () => { | |||
const form = mount( | |||
<GlobalNavSearchForm | |||
appState={{ organizationsEnabled: false }} | |||
currentUser={{ isLoggedIn: false }} | |||
/> | |||
<Search appState={{ organizationsEnabled: false }} currentUser={{ isLoggedIn: false }} /> | |||
); | |||
form.instance().openSearch(); | |||
expect(form.state().open).toBe(true); |
@@ -20,12 +20,12 @@ | |||
// @flow | |||
import React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import GlobalNavSearchFormComponent from '../GlobalNavSearchFormComponent'; | |||
import SearchResult from '../SearchResult'; | |||
function render(props?: Object) { | |||
return shallow( | |||
// $FlowFixMe | |||
<GlobalNavSearchFormComponent | |||
<SearchResult | |||
appState={{ organizationsEnabled: false }} | |||
component={{ key: 'foo', name: 'foo', qualifier: 'TRK', organization: 'bar' }} | |||
innerRef={jest.fn()} |
@@ -0,0 +1,70 @@ | |||
/* | |||
* 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 SearchResults from '../SearchResults'; | |||
it('renders different components and dividers between them', () => { | |||
expect( | |||
shallow( | |||
<SearchResults | |||
allowMore={true} | |||
loadingMore={null} | |||
more={{}} | |||
onMoreClick={jest.fn()} | |||
onSelect={jest.fn()} | |||
renderNoResults={() => <div />} | |||
renderResult={component => <span key={component.key}>{component.name}</span>} | |||
results={{ | |||
TRK: [component('foo'), component('bar')], | |||
BRC: [component('qwe', 'BRC'), component('qux', 'BRC')], | |||
FIL: [component('zux', 'FIL')] | |||
}} | |||
selected={null} | |||
/> | |||
) | |||
).toMatchSnapshot(); | |||
}); | |||
it('renders "Show More" link', () => { | |||
expect( | |||
shallow( | |||
<SearchResults | |||
allowMore={true} | |||
loadingMore={null} | |||
more={{ TRK: 175, BRC: 0 }} | |||
onMoreClick={jest.fn()} | |||
onSelect={jest.fn()} | |||
renderNoResults={() => <div />} | |||
renderResult={component => <span key={component.key}>{component.name}</span>} | |||
results={{ | |||
TRK: [component('foo'), component('bar')], | |||
BRC: [component('qwe', 'BRC'), component('qux', 'BRC')] | |||
}} | |||
selected={null} | |||
/> | |||
) | |||
).toMatchSnapshot(); | |||
}); | |||
function component(key: string, qualifier: string = 'TRK') { | |||
return { key, name: key, qualifier }; | |||
} |
@@ -0,0 +1,17 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
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> | |||
`; |
@@ -8,6 +8,7 @@ exports[`renders favorite 1`] = ` | |||
placement="left" | |||
> | |||
<Link | |||
className="navbar-search-item-link" | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
@@ -34,7 +35,11 @@ exports[`renders favorite 1`] = ` | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
<span | |||
className="navbar-search-item-match" | |||
> | |||
foo | |||
</span> | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
@@ -48,6 +53,7 @@ exports[`renders match 1`] = ` | |||
placement="left" | |||
> | |||
<Link | |||
className="navbar-search-item-link" | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
@@ -71,6 +77,7 @@ exports[`renders match 1`] = ` | |||
/> | |||
</span> | |||
<span | |||
className="navbar-search-item-match" | |||
dangerouslySetInnerHTML={ | |||
Object { | |||
"__html": "f<mark>o</mark>o", | |||
@@ -90,6 +97,7 @@ exports[`renders organizations 1`] = ` | |||
placement="left" | |||
> | |||
<Link | |||
className="navbar-search-item-link" | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
@@ -104,11 +112,6 @@ exports[`renders organizations 1`] = ` | |||
} | |||
} | |||
> | |||
<div | |||
className="pull-right text-muted-2" | |||
> | |||
bar | |||
</div> | |||
<span | |||
className="navbar-search-item-icons little-spacer-right" | |||
> | |||
@@ -120,7 +123,16 @@ exports[`renders organizations 1`] = ` | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
<span | |||
className="navbar-search-item-match" | |||
> | |||
foo | |||
</span> | |||
<div | |||
className="navbar-search-item-right text-muted-2" | |||
> | |||
bar | |||
</div> | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
@@ -134,6 +146,7 @@ exports[`renders organizations 2`] = ` | |||
placement="left" | |||
> | |||
<Link | |||
className="navbar-search-item-link" | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
@@ -159,7 +172,11 @@ exports[`renders organizations 2`] = ` | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
<span | |||
className="navbar-search-item-match" | |||
> | |||
foo | |||
</span> | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
@@ -173,6 +190,7 @@ exports[`renders projects 1`] = ` | |||
placement="left" | |||
> | |||
<Link | |||
className="navbar-search-item-link" | |||
data-key="qwe" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
@@ -187,11 +205,6 @@ exports[`renders projects 1`] = ` | |||
} | |||
} | |||
> | |||
<div | |||
className="pull-right text-muted-2" | |||
> | |||
foo | |||
</div> | |||
<span | |||
className="navbar-search-item-icons little-spacer-right" | |||
> | |||
@@ -203,7 +216,16 @@ exports[`renders projects 1`] = ` | |||
qualifier="BRC" | |||
/> | |||
</span> | |||
qwe | |||
<span | |||
className="navbar-search-item-match" | |||
> | |||
qwe | |||
</span> | |||
<div | |||
className="navbar-search-item-right text-muted-2" | |||
> | |||
foo | |||
</div> | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
@@ -217,6 +239,7 @@ exports[`renders recently browsed 1`] = ` | |||
placement="left" | |||
> | |||
<Link | |||
className="navbar-search-item-link" | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
@@ -242,7 +265,11 @@ exports[`renders recently browsed 1`] = ` | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
<span | |||
className="navbar-search-item-match" | |||
> | |||
foo | |||
</span> | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
@@ -256,6 +283,7 @@ exports[`renders selected 1`] = ` | |||
placement="left" | |||
> | |||
<Link | |||
className="navbar-search-item-link" | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
@@ -278,7 +306,11 @@ exports[`renders selected 1`] = ` | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
<span | |||
className="navbar-search-item-match" | |||
> | |||
foo | |||
</span> | |||
</Link> | |||
</Tooltip> | |||
</li> | |||
@@ -294,6 +326,7 @@ exports[`renders selected 2`] = ` | |||
placement="left" | |||
> | |||
<Link | |||
className="navbar-search-item-link" | |||
data-key="foo" | |||
onClick={[Function]} | |||
onMouseEnter={[Function]} | |||
@@ -316,7 +349,11 @@ exports[`renders selected 2`] = ` | |||
qualifier="TRK" | |||
/> | |||
</span> | |||
foo | |||
<span | |||
className="navbar-search-item-match" | |||
> | |||
foo | |||
</span> | |||
</Link> | |||
</Tooltip> | |||
</li> |
@@ -0,0 +1,84 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`renders "Show More" link 1`] = ` | |||
<ul | |||
className="menu" | |||
> | |||
<li | |||
className="dropdown-header" | |||
> | |||
qualifiers.TRK | |||
</li> | |||
<span> | |||
foo | |||
</span> | |||
<span> | |||
bar | |||
</span> | |||
<SearchShowMore | |||
allowMore={true} | |||
loadingMore={null} | |||
onMoreClick={[Function]} | |||
onSelect={[Function]} | |||
qualifier="TRK" | |||
selected={false} | |||
/> | |||
<li | |||
className="divider" | |||
/> | |||
<li | |||
className="dropdown-header" | |||
> | |||
qualifiers.BRC | |||
</li> | |||
<span> | |||
qwe | |||
</span> | |||
<span> | |||
qux | |||
</span> | |||
</ul> | |||
`; | |||
exports[`renders different components and dividers between them 1`] = ` | |||
<ul | |||
className="menu" | |||
> | |||
<li | |||
className="dropdown-header" | |||
> | |||
qualifiers.TRK | |||
</li> | |||
<span> | |||
foo | |||
</span> | |||
<span> | |||
bar | |||
</span> | |||
<li | |||
className="divider" | |||
/> | |||
<li | |||
className="dropdown-header" | |||
> | |||
qualifiers.BRC | |||
</li> | |||
<span> | |||
qwe | |||
</span> | |||
<span> | |||
qux | |||
</span> | |||
<li | |||
className="divider" | |||
/> | |||
<li | |||
className="dropdown-header" | |||
> | |||
qualifiers.FIL | |||
</li> | |||
<span> | |||
zux | |||
</span> | |||
</ul> | |||
`; |
@@ -0,0 +1,42 @@ | |||
/* | |||
* 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 { sortBy } from 'lodash'; | |||
const ORDER = ['DEV', 'VW', 'SVW', 'TRK', 'BRC', 'FIL', 'UTS']; | |||
export function sortQualifiers(qualifiers: Array<string>) { | |||
return sortBy(qualifiers, qualifier => ORDER.indexOf(qualifier)); | |||
} | |||
export type Component = { | |||
isFavorite?: boolean, | |||
isRecentlyBrowsed?: boolean, | |||
key: string, | |||
match?: string, | |||
name: string, | |||
organization?: string, | |||
project?: string, | |||
qualifier: string | |||
}; | |||
export type Results = { [qualifier: string]: Array<Component> }; | |||
export type More = { [string]: number }; |
@@ -39,6 +39,12 @@ export default class FavoriteBase extends React.PureComponent { | |||
this.toggleFavorite = this.toggleFavorite.bind(this); | |||
} | |||
componentWillReceiveProps(nextProps) { | |||
if (nextProps.favorite !== this.props.favorite && nextProps.favorite !== this.state.favorite) { | |||
this.setState({ favorite: nextProps.favorite }); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} |
@@ -77,6 +77,10 @@ | |||
white-space: nowrap; // as with > li > a | |||
} | |||
.dropdown-item { | |||
padding: 5px 16px; | |||
} | |||
.dropdown-menu .small-divider { | |||
height: 1px; | |||
margin: 4px 20px; |
@@ -77,21 +77,18 @@ | |||
} | |||
} | |||
.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-footer > a > span { | |||
border-bottom: 1px solid @darkGrey; | |||
color: @secondFontColor; | |||
} | |||
.menu-footer-note { | |||
opacity: 0; | |||
transition: opacity 0.3s ease; | |||
} | |||
.menu-footer.active .menu-footer-note { | |||
opacity: 1; | |||
} | |||
} | |||
@@ -138,7 +138,8 @@ | |||
} | |||
.navbar-search-input { | |||
width: 280px; | |||
vertical-align: middle; | |||
width: 310px; | |||
margin-top: 3px; | |||
margin-bottom: 3px; | |||
padding-left: 26px !important; | |||
@@ -160,6 +161,7 @@ | |||
.navbar-search-icon { | |||
position: relative; | |||
vertical-align: middle; | |||
width: 16px; | |||
margin-right: -20px; | |||
color: @secondFontColor; | |||
@@ -169,10 +171,25 @@ | |||
} | |||
} | |||
.navbar-search-item-link { | |||
display: flex !important; | |||
} | |||
.navbar-search-item-match { | |||
flex-grow: 5; | |||
overflow: hidden; | |||
text-overflow: ellipsis; | |||
} | |||
.navbar-search-item-right { | |||
flex-grow: 1; | |||
padding-left: 10px; | |||
text-align: right; | |||
} | |||
.navbar-search-item-icons { | |||
position: relative; | |||
display: inline-block; | |||
vertical-align: middle; | |||
flex-shrink: 0; | |||
width: 16px; | |||
height: 16px; | |||
@@ -186,7 +203,7 @@ | |||
> .icon-star, | |||
> .icon-clock { | |||
z-index: 6; | |||
top: -5px; | |||
top: -4px; | |||
left: -5px; | |||
} | |||
} | |||
@@ -198,14 +215,11 @@ | |||
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-search-no-results { | |||
margin-top: 4px; | |||
padding: 5px 10px; | |||
} | |||
.navbar-global { |
@@ -107,6 +107,14 @@ | |||
text-align: center; | |||
} | |||
.shortcut-button-small { | |||
min-width: 16px; | |||
height: 16px; | |||
line-height: 14px; | |||
margin-left: 4px; | |||
margin-right: 4px; | |||
} | |||
.nav { | |||
margin: 0; |
@@ -260,6 +260,7 @@ new_window=New window | |||
no_data=No data | |||
no_lines_match_your_filter_criteria=No lines match your filter criteria. | |||
no_results=No results | |||
no_results_for_x=No results for "{0}" | |||
no_results_search=We couldn't find any results matching selected criteria. | |||
no_results_search.2=Try to change filters to get some results. | |||
not_authorized=You are not authorized to access this page. | |||
@@ -377,7 +378,7 @@ qualifier.VW=Portfolio | |||
qualifier.SVW=Portfolio | |||
qualifier.FIL=File | |||
qualifier.CLA=File | |||
qualifier.UTS=Unit Test File | |||
qualifier.UTS=Test File | |||
qualifier.DEV=Developer | |||
qualifier.configuration.TRK=Project Configuration | |||
@@ -388,7 +389,7 @@ qualifier.configuration.VW=Portfolio Configuration | |||
qualifier.configuration.SVW=Portfolio Configuration | |||
qualifier.configuration.FIL=File Configuration | |||
qualifier.configuration.CLA=File Configuration | |||
qualifier.configuration.UTS=Unit Test File Configuration | |||
qualifier.configuration.UTS=Test File Configuration | |||
qualifier.configuration.DEV=Developer Configuration | |||
qualifiers.TRK=Projects | |||
@@ -399,7 +400,7 @@ qualifiers.VW=Portfolios | |||
qualifiers.SVW=Portfolios | |||
qualifiers.FIL=Files | |||
qualifiers.CLA=Files | |||
qualifiers.UTS=Unit Test Files | |||
qualifiers.UTS=Test Files | |||
qualifiers.DEV=Developers | |||
qualifiers.all.TRK=All Projects | |||
@@ -1015,8 +1016,9 @@ property.category.scm=SCM | |||
#------------------------------------------------------------------------------ | |||
search.results=results | |||
search.duration=({0} seconds) | |||
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... | |||
search.shortcut_hint=Hint: Press {0} from anywhere to open this search bar. | |||
search.show_more.hint=Press {0} to display | |||
search.placeholder=Search for projects, sub-projects and files... | |||
#------------------------------------------------------------------------------ |