diff options
author | Stas Vilchik <stas-vilchik@users.noreply.github.com> | 2017-05-11 18:17:15 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-05-11 18:17:15 +0200 |
commit | f6554b56731131f5d97b6d2f22801237250d8920 (patch) | |
tree | 722cfe62f2a6cb0e09b12a08aacf112162583ecf /server | |
parent | c1a942976f2ba145f99c4256f7e36c8917554bcb (diff) | |
download | sonarqube-f6554b56731131f5d97b6d2f22801237250d8920.tar.gz sonarqube-f6554b56731131f5d97b6d2f22801237250d8920.zip |
apply search feedback (#2054)
Diffstat (limited to 'server')
20 files changed, 643 insertions, 524 deletions
diff --git a/server/sonar-web/src/main/js/api/components.js b/server/sonar-web/src/main/js/api/components.js index 9552a20dfce..867100c4fae 100644 --- a/server/sonar-web/src/main/js/api/components.js +++ b/server/sonar-web/src/main/js/api/components.js @@ -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(); } diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js index 033bd787f7c..fca0171d9ec 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js @@ -22,7 +22,7 @@ import { connect } from 'react-redux'; import GlobalNavBranding from './GlobalNavBranding'; import GlobalNavMenu from './GlobalNavMenu'; import GlobalNavUser from './GlobalNavUser'; -import 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"> diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js index 8fd6fc4b8e9..6d60902e1c8 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js @@ -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> diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap deleted file mode 100644 index faf3d85dffe..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap +++ /dev/null @@ -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> -`; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js b/server/sonar-web/src/main/js/app/components/search/Search.js index 469a0e673fd..a138b2a1b35 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js +++ b/server/sonar-web/src/main/js/app/components/search/Search.js @@ -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>} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js b/server/sonar-web/src/main/js/app/components/search/SearchResult.js index 9bf2aa3dfc5..2765fa3f0f6 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js +++ b/server/sonar-web/src/main/js/app/components/search/SearchResult.js @@ -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> diff --git a/server/sonar-web/src/main/js/app/components/search/SearchResults.js b/server/sonar-web/src/main/js/app/components/search/SearchResults.js new file mode 100644 index 00000000000..96be176aea6 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/SearchResults.js @@ -0,0 +1,83 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import 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(); + } +} diff --git a/server/sonar-web/src/main/js/app/components/search/SearchShowMore.js b/server/sonar-web/src/main/js/app/components/search/SearchShowMore.js new file mode 100644 index 00000000000..d3a0282849f --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/SearchShowMore.js @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js b/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js index 9ab6beaa056..07a2e347e3a 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js @@ -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); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.js index 73bb358d7a6..ef4aa10dafb 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.js @@ -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()} diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.js b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.js new file mode 100644 index 00000000000..fbba231e38f --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.js @@ -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 }; +} diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap new file mode 100644 index 00000000000..86b9f83f770 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.js.snap index 039ebbd70c1..ce33642ae2c 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.js.snap @@ -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> diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.js.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.js.snap new file mode 100644 index 00000000000..b93d9ef64b5 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.js.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/app/components/search/utils.js b/server/sonar-web/src/main/js/app/components/search/utils.js new file mode 100644 index 00000000000..5ed66863da2 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/utils.js @@ -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 }; diff --git a/server/sonar-web/src/main/js/components/controls/FavoriteBase.js b/server/sonar-web/src/main/js/components/controls/FavoriteBase.js index b417b22e48a..13ec0372fe1 100644 --- a/server/sonar-web/src/main/js/components/controls/FavoriteBase.js +++ b/server/sonar-web/src/main/js/components/controls/FavoriteBase.js @@ -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; } diff --git a/server/sonar-web/src/main/less/components/dropdowns.less b/server/sonar-web/src/main/less/components/dropdowns.less index 394380271cd..382b487d163 100644 --- a/server/sonar-web/src/main/less/components/dropdowns.less +++ b/server/sonar-web/src/main/less/components/dropdowns.less @@ -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; diff --git a/server/sonar-web/src/main/less/components/menu.less b/server/sonar-web/src/main/less/components/menu.less index b8b7a6d7161..226ce16e989 100644 --- a/server/sonar-web/src/main/less/components/menu.less +++ b/server/sonar-web/src/main/less/components/menu.less @@ -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; } } diff --git a/server/sonar-web/src/main/less/components/navbar.less b/server/sonar-web/src/main/less/components/navbar.less index 0190ea4163f..6e3878b0c82 100644 --- a/server/sonar-web/src/main/less/components/navbar.less +++ b/server/sonar-web/src/main/less/components/navbar.less @@ -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 { diff --git a/server/sonar-web/src/main/less/components/ui.less b/server/sonar-web/src/main/less/components/ui.less index 357e37ebb9f..cfdd43a1bde 100644 --- a/server/sonar-web/src/main/less/components/ui.less +++ b/server/sonar-web/src/main/less/components/ui.less @@ -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; |