From f6554b56731131f5d97b6d2f22801237250d8920 Mon Sep 17 00:00:00 2001 From: Stas Vilchik Date: Thu, 11 May 2017 18:17:15 +0200 Subject: apply search feedback (#2054) --- server/sonar-web/src/main/js/api/components.js | 7 +- .../main/js/app/components/nav/global/GlobalNav.js | 4 +- .../components/nav/global/GlobalNavSearchForm.js | 419 --------------------- .../nav/global/GlobalNavSearchFormComponent.js | 113 ------ .../js/app/components/nav/global/GlobalNavUser.js | 10 + .../global/__tests__/GlobalNavSearchForm-test.js | 140 ------- .../__tests__/GlobalNavSearchFormComponent-test.js | 109 ------ .../__snapshots__/GlobalNavSearchForm-test.js.snap | 262 ------------- .../GlobalNavSearchFormComponent-test.js.snap | 323 ---------------- .../src/main/js/app/components/search/Search.js | 375 ++++++++++++++++++ .../main/js/app/components/search/SearchResult.js | 111 ++++++ .../main/js/app/components/search/SearchResults.js | 83 ++++ .../js/app/components/search/SearchShowMore.js | 78 ++++ .../app/components/search/__tests__/Search-test.js | 114 ++++++ .../search/__tests__/SearchResult-test.js | 109 ++++++ .../search/__tests__/SearchResults-test.js | 70 ++++ .../__tests__/__snapshots__/Search-test.js.snap | 17 + .../__snapshots__/SearchResult-test.js.snap | 360 ++++++++++++++++++ .../__snapshots__/SearchResults-test.js.snap | 84 +++++ .../src/main/js/app/components/search/utils.js | 42 +++ .../main/js/components/controls/FavoriteBase.js | 6 + .../src/main/less/components/dropdowns.less | 4 + .../sonar-web/src/main/less/components/menu.less | 27 +- .../sonar-web/src/main/less/components/navbar.less | 36 +- server/sonar-web/src/main/less/components/ui.less | 8 + 25 files changed, 1515 insertions(+), 1396 deletions(-) delete mode 100644 server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js delete mode 100644 server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js delete mode 100644 server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js delete mode 100644 server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js delete mode 100644 server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap delete mode 100644 server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap create mode 100644 server/sonar-web/src/main/js/app/components/search/Search.js create mode 100644 server/sonar-web/src/main/js/app/components/search/SearchResult.js create mode 100644 server/sonar-web/src/main/js/app/components/search/SearchResults.js create mode 100644 server/sonar-web/src/main/js/app/components/search/SearchShowMore.js create mode 100644 server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js create mode 100644 server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.js create mode 100644 server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.js create mode 100644 server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap create mode 100644 server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.js.snap create mode 100644 server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.js.snap create mode 100644 server/sonar-web/src/main/js/app/components/search/utils.js (limited to 'server') 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, more?: string ): Promise => { - 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 {
    - +
  • diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js deleted file mode 100644 index 469a0e673fd..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js +++ /dev/null @@ -1,419 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2017 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -// @flow -import React from 'react'; -import classNames from 'classnames'; -import key from 'keymaster'; -import { debounce, groupBy, keyBy, sortBy, uniqBy } from 'lodash'; -import GlobalNavSearchFormComponent from './GlobalNavSearchFormComponent'; -import type { Component } from './GlobalNavSearchFormComponent'; -import RecentHistory from '../../RecentHistory'; -import DeferredSpinner from '../../../../components/common/DeferredSpinner'; -import { getSuggestions } from '../../../../api/components'; -import { getFavorites } from '../../../../api/favorites'; -import { translate, translateWithParameters } from '../../../../helpers/l10n'; -import { scrollToElement } from '../../../../helpers/scrolling'; -import { getProjectUrl } from '../../../../helpers/urls'; - -type Props = {| - appState: { organizationsEnabled: boolean }, - currentUser: { isLoggedIn: boolean } -|}; - -type State = { - loading: boolean, - loadingMore: ?string, - more: { [string]: number }, - open: boolean, - organizations: { [string]: { name: string } }, - projects: { [string]: { name: string } }, - query: string, - results: { [qualifier: string]: Array }, - selected: ?string, - shortQuery: boolean -}; - -const ORDER = ['DEV', 'VW', 'SVW', 'TRK', 'BRC', 'FIL', 'UTS']; - -export default class GlobalNavSearchForm extends React.PureComponent { - input: HTMLElement; - mounted: boolean; - node: HTMLElement; - nodes: { [string]: HTMLElement }; - props: Props; - state: State; - - static contextTypes = { - router: React.PropTypes.object - }; - - constructor(props: Props) { - super(props); - this.nodes = {}; - this.search = debounce(this.search, 250); - this.fetchFavoritesAndRecentlyBrowsed = debounce(this.fetchFavoritesAndRecentlyBrowsed, 250, { - leading: true - }); - this.state = { - loading: false, - loadingMore: null, - more: {}, - open: false, - organizations: {}, - projects: {}, - query: '', - results: {}, - selected: null, - shortQuery: false - }; - } - - componentDidMount() { - this.mounted = true; - key('s', () => { - this.input.focus(); - this.openSearch(); - return false; - }); - this.fetchFavoritesAndRecentlyBrowsed(); - } - - componentWillUpdate() { - this.nodes = {}; - } - - componentDidUpdate(prevProps: Props, prevState: State) { - if (prevState.selected !== this.state.selected) { - this.scrollToSelected(); - } - } - - componentWillUnmount() { - this.mounted = false; - key.unbind('s'); - window.removeEventListener('click', this.handleClickOutside); - } - - handleClickOutside = (event: { target: HTMLElement }) => { - if (!this.node || !this.node.contains(event.target)) { - this.closeSearch(); - } - }; - - openSearch = () => { - window.addEventListener('click', this.handleClickOutside); - if (!this.state.open) { - this.fetchFavoritesAndRecentlyBrowsed(); - } - this.setState({ open: true }); - }; - - closeSearch = () => { - if (this.input) { - this.input.blur(); - } - window.removeEventListener('click', this.handleClickOutside); - this.setState({ - more: {}, - open: false, - organizations: {}, - projects: {}, - query: '', - results: {}, - selected: null, - shortQuery: false - }); - }; - - getPlainComponentsList = (results: { [qualifier: string]: Array }): Array => - this.sortQualifiers(Object.keys(results)).reduce( - (components, qualifier) => [...components, ...results[qualifier]], - [] - ); - - mergeWithRecentlyBrowsed = (components: Array) => { - const recentlyBrowsed = RecentHistory.get().map(component => ({ - ...component, - isRecentlyBrowsed: true, - qualifier: component.icon.toUpperCase() - })); - return uniqBy([...components, ...recentlyBrowsed], 'key'); - }; - - fetchFavoritesAndRecentlyBrowsed = () => { - const done = (components: Array) => { - const results = groupBy(this.mergeWithRecentlyBrowsed(components), 'qualifier'); - const list = this.getPlainComponentsList(results); - this.setState({ - loading: false, - more: {}, - results, - selected: list.length > 0 ? list[0].key : null - }); - }; - - if (this.props.currentUser.isLoggedIn) { - this.setState({ loading: true }); - getFavorites().then(response => { - if (this.mounted) { - done(response.favorites.map(component => ({ ...component, isFavorite: true }))); - } - }); - } else { - done([]); - } - }; - - search = (query: string) => { - this.setState({ loading: true }); - const recentlyBrowsed = RecentHistory.get().map(component => component.key); - getSuggestions(query, recentlyBrowsed).then(response => { - if (this.mounted) { - const results = {}; - const more = {}; - response.results.forEach(group => { - results[group.q] = group.items.map(item => ({ ...item, qualifier: group.q })); - more[group.q] = group.more; - }); - const list = this.getPlainComponentsList(results); - this.setState(state => ({ - loading: false, - more, - organizations: { ...state.organizations, ...keyBy(response.organizations, 'key') }, - projects: { ...state.projects, ...keyBy(response.projects, 'key') }, - results, - selected: list.length > 0 ? list[0].key : null, - shortQuery: response.warning === 'short_input' - })); - } - }); - }; - - searchMore = (qualifier: string) => { - this.setState({ loading: true, loadingMore: qualifier }); - const recentlyBrowsed = RecentHistory.get().map(component => component.key); - getSuggestions(this.state.query, recentlyBrowsed, qualifier).then(response => { - if (this.mounted) { - const group = response.results.find(group => group.q === qualifier); - const moreResults = (group ? group.items : []).map(item => ({ ...item, qualifier })); - this.setState(state => ({ - loading: false, - loadingMore: null, - more: { ...state.more, [qualifier]: 0 }, - organizations: { ...state.organizations, ...keyBy(response.organizations, 'key') }, - projects: { ...state.projects, ...keyBy(response.projects, 'key') }, - results: { - ...state.results, - [qualifier]: uniqBy([...state.results[qualifier], ...moreResults], 'key') - } - })); - } - }); - }; - - handleQueryChange = (event: { currentTarget: HTMLInputElement }) => { - const query = event.currentTarget.value; - this.setState({ query, shortQuery: query.length === 1 }); - if (query.length === 0) { - this.fetchFavoritesAndRecentlyBrowsed(); - } else if (query.length >= 2) { - this.search(query); - } - }; - - selectPrevious = () => { - this.setState((state: State) => { - const list = this.getPlainComponentsList(state.results); - const index = list.findIndex(component => component.key === state.selected); - return index > 0 ? { selected: list[index - 1].key } : undefined; - }); - }; - - selectNext = () => { - this.setState((state: State) => { - const list = this.getPlainComponentsList(state.results); - const index = list.findIndex(component => component.key === state.selected); - return index >= 0 && index < list.length - 1 ? { selected: list[index + 1].key } : undefined; - }); - }; - - openSelected = () => { - if (this.state.selected) { - this.context.router.push(getProjectUrl(this.state.selected)); - this.closeSearch(); - } - }; - - scrollToSelected = () => { - if (this.state.selected) { - const node = this.nodes[this.state.selected]; - if (node) { - scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node }); - } - } - }; - - handleKeyDown = (event: KeyboardEvent) => { - switch (event.keyCode) { - case 13: - event.preventDefault(); - this.openSelected(); - return; - case 27: - event.preventDefault(); - this.closeSearch(); - return; - case 38: - event.preventDefault(); - this.selectPrevious(); - return; - case 40: - event.preventDefault(); - this.selectNext(); - return; - } - }; - - handleSelect = (selected: string) => { - this.setState({ selected }); - }; - - handleMoreClick = (event: MouseEvent & { currentTarget: HTMLElement }) => { - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.blur(); - const { qualifier } = event.currentTarget.dataset; - this.searchMore(qualifier); - }; - - sortQualifiers = (qualifiers: Array) => - sortBy(qualifiers, qualifier => ORDER.indexOf(qualifier)); - - innerRef = (component: string, node: HTMLElement) => { - this.nodes[component] = node; - }; - - renderComponent = (component: Component) => ( - - ); - - renderComponents = () => { - const qualifiers = Object.keys(this.state.results); - const renderedComponents = []; - - this.sortQualifiers(qualifiers).forEach(qualifier => { - const components = this.state.results[qualifier]; - - if (components.length > 0 && renderedComponents.length > 0) { - renderedComponents.push(
  • ); - } - - if (components.length > 0) { - renderedComponents.push( -
  • - {translate('qualifiers', qualifier)} -
  • - ); - } - - components.forEach(component => { - renderedComponents.push(this.renderComponent(component)); - }); - - const more = this.state.more[qualifier]; - if (more != null && more > 0) { - renderedComponents.push( -
  • - - - {translate('show_more')} - - -
  • - ); - } - }); - - return renderedComponents; - }; - - render() { - const dropdownClassName = classNames('dropdown', 'navbar-search', { open: this.state.open }); - - return ( -
  • - - - - - event.stopPropagation()} - onFocus={this.openSearch} - onKeyDown={this.handleKeyDown} - ref={node => (this.input = node)} - placeholder={translate('search.placeholder')} - type="search" - value={this.state.query} - /> - - {this.state.shortQuery && - 5 - })}> - {translateWithParameters('select2.tooShort', 2)} - } - - {this.state.open && - Object.keys(this.state.results).length > 0 && -
    (this.node = node)}> -
      - {this.renderComponents()} -
    -
    -
    } -
  • - ); - } -} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js deleted file mode 100644 index 9bf2aa3dfc5..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js +++ /dev/null @@ -1,113 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2017 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -// @flow -import React from 'react'; -import { Link } from 'react-router'; -import FavoriteIcon from '../../../../components/common/FavoriteIcon'; -import QualifierIcon from '../../../../components/shared/QualifierIcon'; -import ClockIcon from '../../../../components/common/ClockIcon'; -import Tooltip from '../../../../components/controls/Tooltip'; -import { getProjectUrl } from '../../../../helpers/urls'; - -export type Component = { - isFavorite?: boolean, - isRecentlyBrowsed?: boolean, - key: string, - match?: string, - name: string, - organization?: string, - project?: string, - qualifier: string -}; - -type Props = {| - appState: { organizationsEnabled: boolean }, - component: Component, - innerRef: (string, HTMLElement) => void, - onClose: () => void, - onSelect: string => void, - organizations: { [string]: { name: string } }, - projects: { [string]: { name: string } }, - selected: boolean -|}; - -export default class GlobalNavSearchFormComponent extends React.PureComponent { - props: Props; - - handleMouseEnter = () => { - this.props.onSelect(this.props.component.key); - }; - - renderOrganization = (component: Component) => { - if (!this.props.appState.organizationsEnabled) { - return null; - } - - if (!['VW', 'SVW', 'TRK'].includes(component.qualifier) || component.organization == null) { - return null; - } - - const organization = this.props.organizations[component.organization]; - return organization ?
    {organization.name}
    : null; - }; - - renderProject = (component: Component) => { - if (!['BRC', 'FIL', 'UTS'].includes(component.qualifier) || component.project == null) { - return null; - } - - const project = this.props.projects[component.project]; - return project ?
    {project.name}
    : null; - }; - - render() { - const { component } = this.props; - - return ( -
  • this.props.innerRef(component.key, node)}> - - - - {this.renderOrganization(component)} - {this.renderProject(component)} - - - {component.isFavorite && } - {!component.isFavorite && component.isRecentlyBrowsed && } - - - - {component.match - ? - : component.name} - - - -
  • - ); - } -} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js index 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 {
      +
    • +
      + {currentUser.name} +
      + {currentUser.email != null && +
      + {currentUser.email} +
      } +
    • +
    • {translate('my_account.page')}
    • diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js deleted file mode 100644 index 9ab6beaa056..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js +++ /dev/null @@ -1,140 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2017 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import React from 'react'; -import { shallow, mount } from 'enzyme'; -import type { ShallowWrapper } from 'enzyme'; -import GlobalNavSearchForm from '../GlobalNavSearchForm'; -import { elementKeydown, clickOutside } from '../../../../../helpers/testUtils'; - -function render(props?: Object) { - return shallow( - - ); -} - -function component(key: string, qualifier: string = 'TRK') { - return { key, name: key, qualifier }; -} - -function next(form: ShallowWrapper, expected: string) { - elementKeydown(form.find('input'), 40); - expect(form.state().selected).toBe(expected); -} - -function prev(form: ShallowWrapper, expected: string) { - elementKeydown(form.find('input'), 38); - expect(form.state().selected).toBe(expected); -} - -function select(form: ShallowWrapper, expected: string) { - form.instance().handleSelect(expected); - expect(form.state().selected).toBe(expected); -} - -it('renders different components and dividers between them', () => { - const form = render(); - form.setState({ - open: true, - results: { - TRK: [component('foo'), component('bar')], - BRC: [component('qwe', 'BRC'), component('qux', 'BRC')], - FIL: [component('zux', 'FIL')] - } - }); - expect(form.find('.menu')).toMatchSnapshot(); -}); - -it('renders "Show More" link', () => { - const form = render(); - form.setState({ - more: { TRK: 175, BRC: 0 }, - open: true, - results: { - TRK: [component('foo'), component('bar')], - BRC: [component('qwe', 'BRC'), component('qux', 'BRC')] - } - }); - expect(form.find('.menu')).toMatchSnapshot(); -}); - -it('selects results', () => { - const form = render(); - form.setState({ - open: true, - results: { - TRK: [component('foo'), component('bar')], - BRC: [component('qwe', 'BRC')] - }, - selected: 'foo' - }); - expect(form.state().selected).toBe('foo'); - next(form, 'bar'); - next(form, 'qwe'); - next(form, 'qwe'); - prev(form, 'bar'); - select(form, 'foo'); - prev(form, 'foo'); -}); - -it('opens selected on enter', () => { - const form = render(); - form.setState({ - open: true, - results: { TRK: [component('foo')] }, - selected: 'foo' - }); - const openSelected = jest.fn(); - form.instance().openSelected = openSelected; - elementKeydown(form.find('input'), 13); - expect(openSelected).toBeCalled(); -}); - -it('shows warning about short input', () => { - const form = render(); - form.setState({ shortQuery: true }); - expect(form.find('.navbar-search-input-hint')).toMatchSnapshot(); - form.setState({ query: 'foobar x' }); - expect(form.find('.navbar-search-input-hint')).toMatchSnapshot(); -}); - -it('closes on escape', () => { - const form = render(); - form.instance().openSearch(); - expect(form.state().open).toBe(true); - elementKeydown(form.find('input'), 27); - expect(form.state().open).toBe(false); -}); - -it('closes on click outside', () => { - const form = mount( - - ); - form.instance().openSearch(); - expect(form.state().open).toBe(true); - clickOutside(); - expect(form.state().open).toBe(false); -}); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js deleted file mode 100644 index 73bb358d7a6..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js +++ /dev/null @@ -1,109 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2017 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -// @flow -import React from 'react'; -import { shallow } from 'enzyme'; -import GlobalNavSearchFormComponent from '../GlobalNavSearchFormComponent'; - -function render(props?: Object) { - return shallow( - // $FlowFixMe - - ); -} - -it('renders selected', () => { - const wrapper = render(); - expect(wrapper).toMatchSnapshot(); - wrapper.setProps({ selected: true }); - expect(wrapper).toMatchSnapshot(); -}); - -it('renders match', () => { - const component = { - key: 'foo', - name: 'foo', - match: 'foo', - qualifier: 'TRK', - organization: 'bar' - }; - const wrapper = render({ component }); - expect(wrapper).toMatchSnapshot(); -}); - -it('renders favorite', () => { - const component = { - isFavorite: true, - key: 'foo', - name: 'foo', - qualifier: 'TRK', - organization: 'bar' - }; - const wrapper = render({ component }); - expect(wrapper).toMatchSnapshot(); -}); - -it('renders recently browsed', () => { - const component = { - isRecentlyBrowsed: true, - key: 'foo', - name: 'foo', - qualifier: 'TRK', - organization: 'bar' - }; - const wrapper = render({ component }); - expect(wrapper).toMatchSnapshot(); -}); - -it('renders projects', () => { - const component = { - isRecentlyBrowsed: true, - key: 'qwe', - name: 'qwe', - qualifier: 'BRC', - project: 'foo' - }; - const wrapper = render({ component }); - expect(wrapper).toMatchSnapshot(); -}); - -it('renders organizations', () => { - const component = { - isRecentlyBrowsed: true, - key: 'foo', - name: 'foo', - qualifier: 'TRK', - organization: 'bar' - }; - const wrapper = render({ appState: { organizationsEnabled: true }, component }); - expect(wrapper).toMatchSnapshot(); - wrapper.setProps({ appState: { organizationsEnabled: false } }); - expect(wrapper).toMatchSnapshot(); -}); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap 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`] = ` -
        -
      • - qualifiers.TRK -
      • - - -
      • - - - show_more - - -
      • -
      • -
      • - qualifiers.BRC -
      • - - -
      -`; - -exports[`renders different components and dividers between them 1`] = ` -
        -
      • - qualifiers.TRK -
      • - - -
      • -
      • - qualifiers.BRC -
      • - - -
      • -
      • - qualifiers.FIL -
      • - -
      -`; - -exports[`shows warning about short input 1`] = ` - - select2.tooShort.2 - -`; - -exports[`shows warning about short input 2`] = ` - - select2.tooShort.2 - -`; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap deleted file mode 100644 index 039ebbd70c1..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap +++ /dev/null @@ -1,323 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders favorite 1`] = ` -
    • - - - - - - - foo - - -
    • -`; - -exports[`renders match 1`] = ` -
    • - - - - - - oo", - } - } - /> - - -
    • -`; - -exports[`renders organizations 1`] = ` -
    • - - -
      - bar -
      - - - - - foo - -
      -
    • -`; - -exports[`renders organizations 2`] = ` -
    • - - - - - - - foo - - -
    • -`; - -exports[`renders projects 1`] = ` -
    • - - -
      - foo -
      - - - - - qwe - -
      -
    • -`; - -exports[`renders recently browsed 1`] = ` -
    • - - - - - - - foo - - -
    • -`; - -exports[`renders selected 1`] = ` -
    • - - - - - - foo - - -
    • -`; - -exports[`renders selected 2`] = ` -
    • - - - - - - foo - - -
    • -`; diff --git a/server/sonar-web/src/main/js/app/components/search/Search.js b/server/sonar-web/src/main/js/app/components/search/Search.js new file mode 100644 index 00000000000..a138b2a1b35 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/Search.js @@ -0,0 +1,375 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import classNames from 'classnames'; +import key from 'keymaster'; +import { debounce, 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 }, + currentUser: { isLoggedIn: boolean } +|}; + +type State = { + loading: boolean, + loadingMore: ?string, + more: More, + open: boolean, + organizations: { [string]: { name: string } }, + projects: { [string]: { name: string } }, + query: string, + results: Results, + selected: ?string, + shortQuery: boolean +}; + +export default class Search extends React.PureComponent { + input: HTMLElement; + mounted: boolean; + node: HTMLElement; + nodes: { [string]: HTMLElement }; + props: Props; + state: State; + + static contextTypes = { + router: React.PropTypes.object + }; + + constructor(props: Props) { + super(props); + this.nodes = {}; + this.search = debounce(this.search, 250); + this.state = { + loading: false, + loadingMore: null, + more: {}, + open: false, + organizations: {}, + projects: {}, + query: '', + results: {}, + selected: null, + shortQuery: false + }; + } + + componentDidMount() { + this.mounted = true; + key('s', () => { + this.input.focus(); + this.openSearch(); + return false; + }); + } + + componentWillUpdate() { + this.nodes = {}; + } + + componentDidUpdate(prevProps: Props, prevState: State) { + if (prevState.selected !== this.state.selected) { + this.scrollToSelected(); + } + } + + componentWillUnmount() { + this.mounted = false; + key.unbind('s'); + window.removeEventListener('click', this.handleClickOutside); + } + + handleClickOutside = (event: { target: HTMLElement }) => { + if (!this.node || !this.node.contains(event.target)) { + this.closeSearch(false); + } + }; + + openSearch = () => { + window.addEventListener('click', this.handleClickOutside); + if (!this.state.open && !this.state.query) { + this.search(''); + } + this.setState({ open: true }); + }; + + closeSearch = (clear: boolean = true) => { + if (this.input) { + this.input.blur(); + } + window.removeEventListener('click', this.handleClickOutside); + this.setState( + clear + ? { + more: {}, + open: false, + organizations: {}, + projects: {}, + query: '', + results: {}, + selected: null, + shortQuery: false + } + : { + open: false + } + ); + }; + + getPlainComponentsList = (results: Results, more: More): Array => + 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) => { + const recentlyBrowsed = RecentHistory.get().map(component => ({ + ...component, + isRecentlyBrowsed: true, + qualifier: component.icon.toUpperCase() + })); + return uniqBy([...components, ...recentlyBrowsed], 'key'); + }; + + search = (query: string) => { + this.setState({ loading: true }); + const recentlyBrowsed = RecentHistory.get().map(component => component.key); + getSuggestions(query, recentlyBrowsed).then(response => { + // 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, 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] : null, + shortQuery: response.warning === 'short_input' + })); + } + }); + }; + + searchMore = (qualifier: string) => { + 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 || query.length >= 2) { + this.search(query); + } + }; + + selectPrevious = () => { + 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(({ 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 = () => { + const { selected } = this.state; + if (selected) { + if (selected.startsWith('qualifier###')) { + this.searchMore(selected.substr(12)); + } else { + this.context.router.push(getProjectUrl(selected)); + this.closeSearch(); + } + } + }; + + scrollToSelected = () => { + if (this.state.selected) { + const node = this.nodes[this.state.selected]; + if (node) { + scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node }); + } + } + }; + + handleKeyDown = (event: KeyboardEvent) => { + switch (event.keyCode) { + case 13: + event.preventDefault(); + this.openSelected(); + return; + case 27: + event.preventDefault(); + this.closeSearch(); + return; + case 38: + event.preventDefault(); + this.selectPrevious(); + return; + case 40: + event.preventDefault(); + this.selectNext(); + return; + } + }; + + handleSelect = (selected: string) => { + this.setState({ selected }); + }; + + innerRef = (component: string, node: HTMLElement) => { + this.nodes[component] = node; + }; + + renderResult = (component: Component) => ( + + ); + + renderNoResults = () => ( +
      + {translateWithParameters('no_results_for_x', this.state.query)} +
      + ); + + render() { + const dropdownClassName = classNames('dropdown', 'navbar-search', { open: this.state.open }); + + return ( +
    • + + + + + event.stopPropagation()} + onFocus={this.openSearch} + onKeyDown={this.handleKeyDown} + ref={node => (this.input = node)} + placeholder={translate('search.placeholder')} + type="search" + value={this.state.query} + /> + + {this.state.shortQuery && + 5 + })}> + {translateWithParameters('select2.tooShort', 2)} + } + + {this.state.open && + Object.keys(this.state.results).length > 0 && +
      (this.node = node)}> + +
      s' + ) + }} + /> +
      } +
    • + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/search/SearchResult.js b/server/sonar-web/src/main/js/app/components/search/SearchResult.js new file mode 100644 index 00000000000..2765fa3f0f6 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/SearchResult.js @@ -0,0 +1,111 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import { Link } from 'react-router'; +import 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 }, + component: Component, + innerRef: (string, HTMLElement) => void, + onClose: () => void, + onSelect: string => void, + organizations: { [string]: { name: string } }, + projects: { [string]: { name: string } }, + selected: boolean +|}; + +export default class SearchResult extends React.PureComponent { + props: Props; + + handleMouseEnter = () => { + this.props.onSelect(this.props.component.key); + }; + + renderOrganization = (component: Component) => { + if (!this.props.appState.organizationsEnabled) { + return null; + } + + if (!['VW', 'SVW', 'TRK'].includes(component.qualifier) || component.organization == null) { + return null; + } + + const organization = this.props.organizations[component.organization]; + return organization + ?
      {organization.name}
      + : null; + }; + + renderProject = (component: Component) => { + if (!['BRC', 'FIL', 'UTS'].includes(component.qualifier) || component.project == null) { + return null; + } + + const project = this.props.projects[component.project]; + return project + ?
      {project.name}
      + : null; + }; + + render() { + const { component } = this.props; + + return ( +
    • this.props.innerRef(component.key, node)}> + + + + + {component.isFavorite && } + {!component.isFavorite && component.isRecentlyBrowsed && } + + + + {component.match + ? + : {component.name}} + + {this.renderOrganization(component)} + {this.renderProject(component)} + + + +
    • + ); + } +} 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(
    • ); + } + + if (components.length > 0) { + renderedComponents.push( +
    • + {translate('qualifiers', qualifier)} +
    • + ); + } + + components.forEach(component => renderedComponents.push(this.props.renderResult(component))); + + const more = this.props.more[qualifier]; + if (more != null && more > 0) { + renderedComponents.push( + + ); + } + }); + + return renderedComponents.length > 0 + ?
        {renderedComponents}
      + : 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 ( +
    • + + +
    • + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js b/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js new file mode 100644 index 00000000000..07a2e347e3a --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js @@ -0,0 +1,114 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import type { ShallowWrapper } from 'enzyme'; +import Search from '../Search'; +import { elementKeydown, clickOutside } from '../../../../helpers/testUtils'; + +function render(props?: Object) { + return shallow( + + ); +} + +function component(key: string, qualifier: string = 'TRK') { + return { key, name: key, qualifier }; +} + +function next(form: ShallowWrapper, expected: string) { + elementKeydown(form.find('input'), 40); + expect(form.state().selected).toBe(expected); +} + +function prev(form: ShallowWrapper, expected: string) { + elementKeydown(form.find('input'), 38); + expect(form.state().selected).toBe(expected); +} + +function select(form: ShallowWrapper, expected: string) { + form.instance().handleSelect(expected); + expect(form.state().selected).toBe(expected); +} + +it('selects results', () => { + const form = render(); + form.setState({ + more: { TRK: 15, BRC: 0 }, + open: true, + results: { + TRK: [component('foo'), component('bar')], + BRC: [component('qwe', 'BRC')] + }, + selected: 'foo' + }); + expect(form.state().selected).toBe('foo'); + next(form, 'bar'); + next(form, 'qualifier###TRK'); + next(form, 'qwe'); + next(form, 'qwe'); + prev(form, 'qualifier###TRK'); + prev(form, 'bar'); + select(form, 'foo'); + prev(form, 'foo'); +}); + +it('opens selected on enter', () => { + const form = render(); + form.setState({ + open: true, + results: { TRK: [component('foo')] }, + selected: 'foo' + }); + const openSelected = jest.fn(); + form.instance().openSelected = openSelected; + elementKeydown(form.find('input'), 13); + expect(openSelected).toBeCalled(); +}); + +it('shows warning about short input', () => { + const form = render(); + form.setState({ shortQuery: true }); + expect(form.find('.navbar-search-input-hint')).toMatchSnapshot(); + form.setState({ query: 'foobar x' }); + expect(form.find('.navbar-search-input-hint')).toMatchSnapshot(); +}); + +it('closes on escape', () => { + const form = render(); + form.instance().openSearch(); + expect(form.state().open).toBe(true); + elementKeydown(form.find('input'), 27); + expect(form.state().open).toBe(false); +}); + +it('closes on click outside', () => { + const form = mount( + + ); + form.instance().openSearch(); + expect(form.state().open).toBe(true); + clickOutside(); + expect(form.state().open).toBe(false); +}); diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.js b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.js new file mode 100644 index 00000000000..ef4aa10dafb --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.js @@ -0,0 +1,109 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import { shallow } from 'enzyme'; +import SearchResult from '../SearchResult'; + +function render(props?: Object) { + return shallow( + // $FlowFixMe + + ); +} + +it('renders selected', () => { + const wrapper = render(); + expect(wrapper).toMatchSnapshot(); + wrapper.setProps({ selected: true }); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders match', () => { + const component = { + key: 'foo', + name: 'foo', + match: 'foo', + qualifier: 'TRK', + organization: 'bar' + }; + const wrapper = render({ component }); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders favorite', () => { + const component = { + isFavorite: true, + key: 'foo', + name: 'foo', + qualifier: 'TRK', + organization: 'bar' + }; + const wrapper = render({ component }); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders recently browsed', () => { + const component = { + isRecentlyBrowsed: true, + key: 'foo', + name: 'foo', + qualifier: 'TRK', + organization: 'bar' + }; + const wrapper = render({ component }); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders projects', () => { + const component = { + isRecentlyBrowsed: true, + key: 'qwe', + name: 'qwe', + qualifier: 'BRC', + project: 'foo' + }; + const wrapper = render({ component }); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders organizations', () => { + const component = { + isRecentlyBrowsed: true, + key: 'foo', + name: 'foo', + qualifier: 'TRK', + organization: 'bar' + }; + const wrapper = render({ appState: { organizationsEnabled: true }, component }); + expect(wrapper).toMatchSnapshot(); + wrapper.setProps({ appState: { organizationsEnabled: false } }); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/app/components/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( +
      } + renderResult={component => {component.name}} + 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( +
      } + renderResult={component => {component.name}} + 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`] = ` + + select2.tooShort.2 + +`; + +exports[`shows warning about short input 2`] = ` + + select2.tooShort.2 + +`; diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.js.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.js.snap new file mode 100644 index 00000000000..ce33642ae2c --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.js.snap @@ -0,0 +1,360 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders favorite 1`] = ` +
    • + + + + + + + + foo + + + +
    • +`; + +exports[`renders match 1`] = ` +
    • + + + + + + oo", + } + } + /> + + +
    • +`; + +exports[`renders organizations 1`] = ` +
    • + + + + + + + + foo + +
      + bar +
      + +
      +
    • +`; + +exports[`renders organizations 2`] = ` +
    • + + + + + + + + foo + + + +
    • +`; + +exports[`renders projects 1`] = ` +
    • + + + + + + + + qwe + +
      + foo +
      + +
      +
    • +`; + +exports[`renders recently browsed 1`] = ` +
    • + + + + + + + + foo + + + +
    • +`; + +exports[`renders selected 1`] = ` +
    • + + + + + + + foo + + + +
    • +`; + +exports[`renders selected 2`] = ` +
    • + + + + + + + foo + + + +
    • +`; diff --git a/server/sonar-web/src/main/js/app/components/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`] = ` +
        +
      • + qualifiers.TRK +
      • + + foo + + + bar + + +
      • +
      • + qualifiers.BRC +
      • + + qwe + + + qux + +
      +`; + +exports[`renders different components and dividers between them 1`] = ` +
        +
      • + qualifiers.TRK +
      • + + foo + + + bar + +
      • +
      • + qualifiers.BRC +
      • + + qwe + + + qux + +
      • +
      • + qualifiers.FIL +
      • + + zux + +
      +`; 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) { + 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 }; + +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; -- cgit v1.2.3