소스 검색

apply search feedback (#2054)

tags/6.4-RC1
Stas Vilchik 7 년 전
부모
커밋
f6554b5673
21개의 변경된 파일650개의 추가작업 그리고 529개의 파일을 삭제
  1. 5
    2
      server/sonar-web/src/main/js/api/components.js
  2. 2
    2
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js
  3. 10
    0
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js
  4. 0
    262
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap
  5. 112
    156
      server/sonar-web/src/main/js/app/components/search/Search.js
  6. 22
    24
      server/sonar-web/src/main/js/app/components/search/SearchResult.js
  7. 83
    0
      server/sonar-web/src/main/js/app/components/search/SearchResults.js
  8. 78
    0
      server/sonar-web/src/main/js/app/components/search/SearchShowMore.js
  9. 7
    33
      server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js
  10. 2
    2
      server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.js
  11. 70
    0
      server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.js
  12. 17
    0
      server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap
  13. 54
    17
      server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.js.snap
  14. 84
    0
      server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.js.snap
  15. 42
    0
      server/sonar-web/src/main/js/app/components/search/utils.js
  16. 6
    0
      server/sonar-web/src/main/js/components/controls/FavoriteBase.js
  17. 4
    0
      server/sonar-web/src/main/less/components/dropdowns.less
  18. 12
    15
      server/sonar-web/src/main/less/components/menu.less
  19. 25
    11
      server/sonar-web/src/main/less/components/navbar.less
  20. 8
    0
      server/sonar-web/src/main/less/components/ui.less
  21. 7
    5
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 5
- 2
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();
}

+ 2
- 2
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">

+ 10
- 0
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>

+ 0
- 262
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap 파일 보기

@@ -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>
`;

server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js → 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>}

server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js → 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>

+ 83
- 0
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();
}
}

+ 78
- 0
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>
);
}
}

server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js → 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);

server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js → 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()}

+ 70
- 0
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 };
}

+ 17
- 0
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>
`;

server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap → 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>

+ 84
- 0
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>
`;

+ 42
- 0
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 };

+ 6
- 0
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;
}

+ 4
- 0
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;

+ 12
- 15
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;
}
}


+ 25
- 11
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 {

+ 8
- 0
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;

+ 7
- 5
sonar-core/src/main/resources/org/sonar/l10n/core.properties 파일 보기

@@ -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...


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

Loading…
취소
저장