diff options
author | Stas Vilchik <stas.vilchik@sonarsource.com> | 2018-09-05 13:51:44 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-09-05 20:21:03 +0200 |
commit | a52c25d808596e62e66a5977c3314fb1a00ac76e (patch) | |
tree | 2ee537d3239e7febc0f4e726a9c161ab15dd587a | |
parent | 0928b5632fd08edb53614a125bc52b3f5bee2b7d (diff) | |
download | sonarqube-a52c25d808596e62e66a5977c3314fb1a00ac76e.tar.gz sonarqube-a52c25d808596e62e66a5977c3314fb1a00ac76e.zip |
rewrite global search in ts (#680)
18 files changed, 556 insertions, 390 deletions
diff --git a/server/sonar-web/src/main/js/app/components/RecentHistory.js b/server/sonar-web/src/main/js/app/components/RecentHistory.ts index 0967b38c07a..4d10570d82f 100644 --- a/server/sonar-web/src/main/js/app/components/RecentHistory.js +++ b/server/sonar-web/src/main/js/app/components/RecentHistory.ts @@ -17,50 +17,47 @@ * 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 { get, remove, save } from '../../helpers/storage'; const RECENT_HISTORY = 'sonar_recent_history'; const HISTORY_LIMIT = 10; -/*:: -type History = Array<{ - key: string, - name: string, - icon: string, - organization?: string +export type History = Array<{ + key: string; + name: string; + icon: string; + organization?: string; }>; -*/ export default class RecentHistory { - static get() /*: History */ { + static get(): History { const history = get(RECENT_HISTORY); if (history == null) { return []; } else { try { return JSON.parse(history); - } catch (e) { + } catch { remove(RECENT_HISTORY); return []; } } } - static set(newHistory /*: History */) /*: void */ { + static set(newHistory: History) { save(RECENT_HISTORY, JSON.stringify(newHistory)); } - static clear() /*: void */ { + static clear() { remove(RECENT_HISTORY); } static add( - componentKey /*: string */, - componentName /*: string */, - icon /*: string */, - organization /*: string | void */ - ) /*: void */ { + componentKey: string, + componentName: string, + icon: string, + organization: string | undefined + ) { const sonarHistory = RecentHistory.get(); const newEntry = { key: componentKey, name: componentName, icon, organization }; let newHistory = sonarHistory.filter(entry => entry.key !== newEntry.key); @@ -69,7 +66,7 @@ export default class RecentHistory { RecentHistory.set(newHistory); } - static remove(componentKey /*: string */) /*: void */ { + static remove(componentKey: string) { const history = RecentHistory.get(); const newHistory = history.filter(entry => entry.key !== componentKey); RecentHistory.set(newHistory); diff --git a/server/sonar-web/src/main/js/app/components/__tests__/RecentHistory-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/RecentHistory-test.tsx new file mode 100644 index 00000000000..8898e899200 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/__tests__/RecentHistory-test.tsx @@ -0,0 +1,103 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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 RecentHistory, { History } from '../RecentHistory'; +import { get, remove, save } from '../../../helpers/storage'; + +jest.mock('../../../helpers/storage', () => ({ + get: jest.fn(), + remove: jest.fn(), + save: jest.fn() +})); + +beforeEach(() => { + (get as jest.Mock).mockClear(); + (remove as jest.Mock).mockClear(); + (save as jest.Mock).mockClear(); +}); + +it('should get existing history', () => { + const history = [{ key: 'foo', name: 'Foo', icon: 'TRK' }]; + (get as jest.Mock).mockReturnValueOnce(JSON.stringify(history)); + expect(RecentHistory.get()).toEqual(history); + expect(get).toBeCalledWith('sonar_recent_history'); +}); + +it('should get empty history', () => { + (get as jest.Mock).mockReturnValueOnce(null); + expect(RecentHistory.get()).toEqual([]); + expect(get).toBeCalledWith('sonar_recent_history'); +}); + +it('should return [] and clear history in case of failure', () => { + (get as jest.Mock).mockReturnValueOnce('not a json'); + expect(RecentHistory.get()).toEqual([]); + expect(get).toBeCalledWith('sonar_recent_history'); + expect(remove).toBeCalledWith('sonar_recent_history'); +}); + +it('should save history', () => { + const history = [{ key: 'foo', name: 'Foo', icon: 'TRK' }]; + RecentHistory.set(history); + expect(save).toBeCalledWith('sonar_recent_history', JSON.stringify(history)); +}); + +it('should clear history', () => { + RecentHistory.clear(); + expect(remove).toBeCalledWith('sonar_recent_history'); +}); + +it('should add item to history', () => { + const history = [{ key: 'foo', name: 'Foo', icon: 'TRK' }]; + (get as jest.Mock).mockReturnValueOnce(JSON.stringify(history)); + RecentHistory.add('bar', 'Bar', 'VW', 'org'); + expect(save).toBeCalledWith( + 'sonar_recent_history', + JSON.stringify([{ key: 'bar', name: 'Bar', icon: 'VW', organization: 'org' }, ...history]) + ); +}); + +it('should keep 10 items maximum', () => { + const history: History = []; + for (let i = 0; i < 10; i++) { + history.push({ key: `key-${i}`, name: `name-${i}`, icon: 'TRK' }); + } + (get as jest.Mock).mockReturnValueOnce(JSON.stringify(history)); + RecentHistory.add('bar', 'Bar', 'VW', 'org'); + expect(save).toBeCalledWith( + 'sonar_recent_history', + JSON.stringify([ + { key: 'bar', name: 'Bar', icon: 'VW', organization: 'org' }, + ...history.slice(0, 9) + ]) + ); +}); + +it('should remove component from history', () => { + const history: History = []; + for (let i = 0; i < 10; i++) { + history.push({ key: `key-${i}`, name: `name-${i}`, icon: 'TRK' }); + } + (get as jest.Mock).mockReturnValueOnce(JSON.stringify(history)); + RecentHistory.remove('key-5'); + expect(save).toBeCalledWith( + 'sonar_recent_history', + JSON.stringify([...history.slice(0, 5), ...history.slice(6)]) + ); +}); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap index 1ffdab8f252..6598367a9cc 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap @@ -47,7 +47,7 @@ exports[`should render for SonarCloud 1`] = ` suggestions={Array []} tooltip={false} /> - <Search + <withRouter(Search) appState={ Object { "canAdmin": false, @@ -127,7 +127,7 @@ exports[`should render for SonarQube 1`] = ` suggestions={Array []} tooltip={true} /> - <Search + <withRouter(Search) appState={ Object { "canAdmin": false, diff --git a/server/sonar-web/src/main/js/app/components/search/Search.d.ts b/server/sonar-web/src/main/js/app/components/search/Search.d.ts deleted file mode 100644 index 58ceb74bdc0..00000000000 --- a/server/sonar-web/src/main/js/app/components/search/Search.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 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 * as React from 'react'; -import { CurrentUser, AppState } from '../../types'; - -export interface Props { - appState: Pick<AppState, 'organizationsEnabled'>; - currentUser: CurrentUser; -} - -export default class Search extends React.PureComponent<Props> {} 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.tsx index b65be42af15..9d91d8b3acd 100644 --- a/server/sonar-web/src/main/js/app/components/search/Search.js +++ b/server/sonar-web/src/main/js/app/components/search/Search.tsx @@ -17,14 +17,12 @@ * 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 PropTypes from 'prop-types'; -import key from 'keymaster'; +import * as React from 'react'; +import * as key from 'keymaster'; import { debounce, keyBy, uniqBy } from 'lodash'; import { FormattedMessage } from 'react-intl'; -import { sortQualifiers } from './utils'; -/*:: import type { Component, More, Results } from './utils'; */ +import { withRouter, WithRouterProps } from 'react-router'; +import { sortQualifiers, More, Results, ComponentResult } from './utils'; import RecentHistory from '../RecentHistory'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; import { DropdownOverlay } from '../../../components/controls/Dropdown'; @@ -36,60 +34,50 @@ import { getSuggestions } from '../../../api/components'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { scrollToElement } from '../../../helpers/scrolling'; import { getProjectUrl } from '../../../helpers/urls'; +import { AppState, CurrentUser } from '../../types'; import './Search.css'; const SearchResults = lazyLoad(() => import('./SearchResults')); const SearchResult = lazyLoad(() => import('./SearchResult')); -/*:: -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: HTMLInputElement | null; */ - /*:: mounted: boolean; */ - /*:: node: HTMLElement; */ - /*:: nodes: { [string]: HTMLElement }; -*/ - /*:: props: Props; */ - /*:: state: State; */ - - static contextTypes = { - router: PropTypes.object - }; +interface OwnProps { + appState: Pick<AppState, 'organizationsEnabled'>; + currentUser: CurrentUser; +} + +type Props = OwnProps & WithRouterProps; + +interface State { + loading: boolean; + loadingMore?: string; + more: More; + open: boolean; + organizations: { [key: string]: { name: string } }; + projects: { [key: string]: { name: string } }; + query: string; + results: Results; + selected?: string; + shortQuery: boolean; +} - constructor(props /*: Props */) { +export class Search extends React.PureComponent<Props, State> { + input?: HTMLInputElement | null; + node?: HTMLElement | null; + nodes: { [x: string]: HTMLElement }; + mounted = false; + + 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 }; } @@ -97,9 +85,7 @@ export default class Search extends React.PureComponent { componentDidMount() { this.mounted = true; key('s', () => { - if (this.input) { - this.input.focus(); - } + this.focusInput(); this.openSearch(); return false; }); @@ -109,7 +95,7 @@ export default class Search extends React.PureComponent { this.nodes = {}; } - componentDidUpdate(prevProps /*: Props */, prevState /*: State */) { + componentDidUpdate(_prevProps: Props, prevState: State) { if (prevState.selected !== this.state.selected) { this.scrollToSelected(); } @@ -120,6 +106,12 @@ export default class Search extends React.PureComponent { key.unbind('s'); } + focusInput = () => { + if (this.input) { + this.input.focus(); + } + }; + handleClickOutside = () => { this.closeSearch(false); }; @@ -142,29 +134,27 @@ export default class Search extends React.PureComponent { this.setState({ open: true }); }; - closeSearch = (clear /*: boolean */ = true) => { + closeSearch = (clear = true) => { if (this.input) { this.input.blur(); } - this.setState( - clear - ? { - more: {}, - open: false, - organizations: {}, - projects: {}, - query: '', - results: {}, - selected: null, - shortQuery: false - } - : { - open: false - } - ); + if (clear) { + this.setState({ + more: {}, + open: false, + organizations: {}, + projects: {}, + query: '', + results: {}, + selected: undefined, + shortQuery: false + }); + } else { + this.setState({ open: false }); + } }; - getPlainComponentsList = (results /*: Results */, more /*: More */) => + getPlainComponentsList = (results: Results, more: More) => sortQualifiers(Object.keys(results)).reduce((components, qualifier) => { const next = [...components, ...results[qualifier].map(component => component.key)]; if (more[qualifier]) { @@ -173,7 +163,7 @@ export default class Search extends React.PureComponent { return next; }, []); - mergeWithRecentlyBrowsed = (components /*: Array<Component> */) => { + mergeWithRecentlyBrowsed = (components: ComponentResult[]) => { const recentlyBrowsed = RecentHistory.get().map(component => ({ ...component, isRecentlyBrowsed: true, @@ -188,7 +178,7 @@ export default class Search extends React.PureComponent { } }; - search = (query /*: string */) => { + search = (query: string) => { if (query.length === 0 || query.length >= 2) { this.setState({ loading: true }); const recentlyBrowsed = RecentHistory.get().map(component => component.key); @@ -196,8 +186,8 @@ export default class Search extends React.PureComponent { // 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 = {}; + const results: Results = {}; + const more: More = {}; response.results.forEach(group => { results[group.q] = group.items.map(item => ({ ...item, qualifier: group.q })); more[group.q] = group.more; @@ -209,7 +199,7 @@ export default class Search extends React.PureComponent { organizations: { ...state.organizations, ...keyBy(response.organizations, 'key') }, projects: { ...state.projects, ...keyBy(response.projects, 'key') }, results, - selected: list.length > 0 ? list[0] : null, + selected: list.length > 0 ? list[0] : undefined, shortQuery: query.length > 2 && response.warning === 'short_input' })); } @@ -219,55 +209,60 @@ export default class Search extends React.PureComponent { } }; - 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 - })); - if (this.input) { - this.input.focus(); - } - } - }, this.stopLoading); + searchMore = (qualifier: string) => { + const { query } = this.state; + if (query.length === 1) { + return; } + + this.setState({ loading: true, loadingMore: qualifier }); + const recentlyBrowsed = RecentHistory.get().map(component => component.key); + getSuggestions(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: undefined, + 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 + })); + this.focusInput(); + } + }, this.stopLoading); }; - handleQueryChange = (query /*: string */) => { + handleQueryChange = (query: string) => { this.setState({ query, shortQuery: query.length === 1 }); this.search(query); }; selectPrevious = () => { - this.setState(({ more, results, selected } /*: State */) => { + this.setState(({ more, results, selected }) => { if (selected) { const list = this.getPlainComponentsList(results, more); const index = list.indexOf(selected); - return index > 0 ? { selected: list[index - 1] } : undefined; + return index > 0 ? { selected: list[index - 1] } : null; + } else { + return null; } }); }; selectNext = () => { - this.setState(({ more, results, selected } /*: State */) => { + this.setState(({ more, results, selected }) => { 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; + return index >= 0 && index < list.length - 1 ? { selected: list[index + 1] } : null; + } else { + return null; } }); }; @@ -278,7 +273,7 @@ export default class Search extends React.PureComponent { if (selected.startsWith('qualifier###')) { this.searchMore(selected.substr(12)); } else { - this.context.router.push(getProjectUrl(selected)); + this.props.router.push(getProjectUrl(selected)); this.closeSearch(); } } @@ -287,13 +282,13 @@ export default class Search extends React.PureComponent { scrollToSelected = () => { if (this.state.selected) { const node = this.nodes[this.state.selected]; - if (node) { + if (node && this.node) { scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node }); } } }; - handleKeyDown = (event /*: KeyboardEvent */) => { + handleKeyDown = (event: React.KeyboardEvent) => { switch (event.keyCode) { case 13: event.preventDefault(); @@ -312,19 +307,21 @@ export default class Search extends React.PureComponent { } }; - handleSelect = (selected /*: string */) => { + handleSelect = (selected: string) => { this.setState({ selected }); }; - innerRef = (component /*: string */, node /*: HTMLElement */) => { - this.nodes[component] = node; + innerRef = (component: string, node: HTMLElement | null) => { + if (node) { + this.nodes[component] = node; + } }; - searchInputRef = (node /*: HTMLInputElement | null */) => { + searchInputRef = (node: HTMLInputElement | null) => { this.input = node; }; - renderResult = (component /*: Component */) => ( + renderResult = (component: ComponentResult) => ( <SearchResult appState={this.props.appState} component={component} @@ -407,3 +404,5 @@ export default class Search extends React.PureComponent { ); } } + +export default withRouter<OwnProps>(Search); 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.tsx index c8426ea96a9..7dd26bd190d 100644 --- a/server/sonar-web/src/main/js/app/components/search/SearchResult.js +++ b/server/sonar-web/src/main/js/app/components/search/SearchResult.tsx @@ -17,41 +17,36 @@ * 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 * as React from 'react'; import { Link } from 'react-router'; -/*:: import type { Component } from './utils'; */ +import { ComponentResult } from './utils'; import FavoriteIcon from '../../../components/icons-components/FavoriteIcon'; import QualifierIcon from '../../../components/icons-components/QualifierIcon'; import ClockIcon from '../../../components/icons-components/ClockIcon'; import Tooltip from '../../../components/controls/Tooltip'; import { getProjectUrl } from '../../../helpers/urls'; +import { AppState } from '../../types'; + +interface Props { + appState: Pick<AppState, 'organizationsEnabled'>; + component: ComponentResult; + innerRef: (componentKey: string, node: HTMLElement | null) => void; + onClose: () => void; + onSelect: (componentKey: string) => void; + organizations: { [key: string]: { name: string } }; + projects: { [key: string]: { name: string } }; + selected: boolean; +} -/*:: -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 -|}; -*/ - -/*:: -type State = { - tooltipVisible: boolean -}; -*/ +interface State { + tooltipVisible: boolean; +} const TOOLTIP_DELAY = 1000; -export default class SearchResult extends React.PureComponent { - /*:: interval: ?number; */ - /*:: props: Props; */ - state /*: State */ = { tooltipVisible: false }; +export default class SearchResult extends React.PureComponent<Props, State> { + interval?: number; + state: State = { tooltipVisible: false }; componentDidMount() { if (this.props.selected) { @@ -59,7 +54,7 @@ export default class SearchResult extends React.PureComponent { } } - componentWillReceiveProps(nextProps /*: Props */) { + componentWillReceiveProps(nextProps: Props) { if (!this.props.selected && nextProps.selected) { this.scheduleTooltip(); } else if (this.props.selected && !nextProps.selected) { @@ -73,12 +68,14 @@ export default class SearchResult extends React.PureComponent { } scheduleTooltip = () => { - this.interval = setTimeout(() => this.setState({ tooltipVisible: true }), TOOLTIP_DELAY); + this.interval = window.setTimeout(() => { + this.setState({ tooltipVisible: true }); + }, TOOLTIP_DELAY); }; unscheduleTooltip = () => { if (this.interval) { - clearInterval(this.interval); + window.clearInterval(this.interval); } }; @@ -86,15 +83,12 @@ export default class SearchResult extends React.PureComponent { this.props.onSelect(this.props.component.key); }; - renderOrganization = (component /*: Component */) => { + renderOrganization = (component: ComponentResult) => { if (!this.props.appState.organizationsEnabled) { return null; } - if ( - !['VW', 'SVW', 'APP', 'TRK'].includes(component.qualifier) || - component.organization == null - ) { + if (!['VW', 'SVW', 'APP', 'TRK'].includes(component.qualifier) || !component.organization) { return null; } @@ -104,7 +98,7 @@ export default class SearchResult extends React.PureComponent { ) : null; }; - renderProject = (component /*: Component */) => { + renderProject = (component: ComponentResult) => { if (!['BRC', 'FIL', 'UTS'].includes(component.qualifier) || component.project == null) { return null; } 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 deleted file mode 100644 index 2d8e3b7532f..00000000000 --- a/server/sonar-web/src/main/js/app/components/search/SearchResults.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 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 className="divider" key={`divider-${qualifier}`} />); - } - - if (components.length > 0) { - renderedComponents.push( - <li className="menu-header" key={`header-${qualifier}`}> - {translate('qualifiers', qualifier)} - </li> - ); - } - - components.forEach(component => renderedComponents.push(this.props.renderResult(component))); - - const more = this.props.more[qualifier]; - if (more != null && more > 0) { - renderedComponents.push( - <SearchShowMore - allowMore={this.props.allowMore} - key={`more-${qualifier}`} - loadingMore={this.props.loadingMore} - onMoreClick={this.props.onMoreClick} - onSelect={this.props.onSelect} - qualifier={qualifier} - selected={this.props.selected === `qualifier###${qualifier}`} - /> - ); - } - }); - - return renderedComponents.length > 0 ? ( - <ul className="menu">{renderedComponents}</ul> - ) : ( - this.props.renderNoResults() - ); - } -} diff --git a/server/sonar-web/src/main/js/app/components/search/SearchResults.tsx b/server/sonar-web/src/main/js/app/components/search/SearchResults.tsx new file mode 100644 index 00000000000..0c02e8fd58a --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/SearchResults.tsx @@ -0,0 +1,79 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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 * as React from 'react'; +import SearchShowMore from './SearchShowMore'; +import { sortQualifiers, More, ComponentResult, Results } from './utils'; +import { translate } from '../../../helpers/l10n'; + +export interface Props { + allowMore: boolean; + loadingMore?: string; + more: More; + onMoreClick: (qualifier: string) => void; + onSelect: (componentKey: string) => void; + renderNoResults: () => React.ReactElement<any>; + renderResult: (component: ComponentResult) => React.ReactNode; + results: Results; + selected?: string; +} + +export default function SearchResults(props: Props): React.ReactElement<Props> { + const qualifiers = Object.keys(props.results); + const renderedComponents: React.ReactNode[] = []; + + sortQualifiers(qualifiers).forEach(qualifier => { + const components = props.results[qualifier]; + + if (components.length > 0 && renderedComponents.length > 0) { + renderedComponents.push(<li className="divider" key={`divider-${qualifier}`} />); + } + + if (components.length > 0) { + renderedComponents.push( + <li className="menu-header" key={`header-${qualifier}`}> + {translate('qualifiers', qualifier)} + </li> + ); + } + + components.forEach(component => renderedComponents.push(props.renderResult(component))); + + const more = props.more[qualifier]; + if (more !== undefined && more > 0) { + renderedComponents.push( + <SearchShowMore + allowMore={props.allowMore} + key={`more-${qualifier}`} + loadingMore={props.loadingMore} + onMoreClick={props.onMoreClick} + onSelect={props.onSelect} + qualifier={qualifier} + selected={props.selected === `qualifier###${qualifier}`} + /> + ); + } + }); + + return renderedComponents.length > 0 ? ( + <ul className="menu">{renderedComponents}</ul> + ) : ( + 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.tsx index a05ea9b4e98..7b1d9eb479b 100644 --- a/server/sonar-web/src/main/js/app/components/search/SearchShowMore.js +++ b/server/sonar-web/src/main/js/app/components/search/SearchShowMore.tsx @@ -17,37 +17,36 @@ * 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 * as React from 'react'; +import * as 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; */ +interface Props { + allowMore: boolean; + loadingMore?: string; + onMoreClick: (qualifier: string) => void; + onSelect: (qualifier: string) => void; + qualifier: string; + selected: boolean; +} - handleMoreClick = (event /*: MouseEvent & { currentTarget: HTMLElement } */) => { +export default class SearchShowMore extends React.PureComponent<Props> { + handleMoreClick = (event: React.MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.blur(); const { qualifier } = event.currentTarget.dataset; - this.props.onMoreClick(qualifier); + if (qualifier) { + this.props.onMoreClick(qualifier); + } }; - handleMoreMouseEnter = (event /*: { currentTarget: HTMLElement } */) => { + handleMoreMouseEnter = (event: React.MouseEvent<HTMLAnchorElement>) => { const { qualifier } = event.currentTarget.dataset; - this.props.onSelect(`qualifier###${qualifier}`); + if (qualifier) { + this.props.onSelect(`qualifier###${qualifier}`); + } }; render() { 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.tsx index 01168e7a49b..a3e6645a611 100644 --- 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.tsx @@ -17,43 +17,13 @@ * 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( - <Search - appState={{ organizationsEnabled: false }} - currentUser={{ isLoggedIn: false }} - {...props} - /> - ); -} - -function component(key /*: string */, qualifier /*: string */ = 'TRK') { - return { key, name: key, qualifier }; -} - -function next(form /*: ShallowWrapper */, expected /*: string */) { - elementKeydown(form.find('SearchBox'), 40); - expect(form.state().selected).toBe(expected); -} - -function prev(form /*: ShallowWrapper */, expected /*: string */) { - elementKeydown(form.find('SearchBox'), 38); - expect(form.state().selected).toBe(expected); -} - -function select(form /*: ShallowWrapper */, expected /*: string */) { - form.instance().handleSelect(expected); - expect(form.state().selected).toBe(expected); -} +import * as React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { Search } from '../Search'; +import { elementKeydown } from '../../../../helpers/testUtils'; it('selects results', () => { - const form = render(); + const form = shallowRender(); form.setState({ more: { TRK: 15, BRC: 0 }, open: true, @@ -75,22 +45,52 @@ it('selects results', () => { }); it('opens selected on enter', () => { - const form = render(); + const form = shallowRender(); form.setState({ open: true, results: { TRK: [component('foo')] }, selected: 'foo' }); const openSelected = jest.fn(); - form.instance().openSelected = openSelected; + (form.instance() as Search).openSelected = openSelected; elementKeydown(form.find('SearchBox'), 13); expect(openSelected).toBeCalled(); }); it('shows warning about short input', () => { - const form = render(); + const form = shallowRender(); 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(); }); + +function shallowRender(props: Partial<Search['props']> = {}) { + return shallow( + // @ts-ignore + <Search + appState={{ organizationsEnabled: false }} + currentUser={{ isLoggedIn: false }} + {...props} + /> + ); +} + +function component(key: string, qualifier = 'TRK') { + return { key, name: key, qualifier }; +} + +function next(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) { + elementKeydown(form.find('SearchBox'), 40); + expect(form.state().selected).toBe(expected); +} + +function prev(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) { + elementKeydown(form.find('SearchBox'), 38); + expect(form.state().selected).toBe(expected); +} + +function select(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) { + (form.instance() as Search).handleSelect(expected); + expect(form.state().selected).toBe(expected); +} 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.tsx index 91ca09f72aa..83cfe819c49 100644 --- 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.tsx @@ -17,32 +17,14 @@ * 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 * as React from 'react'; import { shallow } from 'enzyme'; import SearchResult from '../SearchResult'; -function render(props /*: ?Object */) { - return shallow( - // $FlowFixMe - <SearchResult - appState={{ organizationsEnabled: false }} - component={{ key: 'foo', name: 'foo', qualifier: 'TRK', organization: 'bar' }} - innerRef={jest.fn()} - onClose={jest.fn()} - onSelect={jest.fn()} - organizations={{ bar: { name: 'bar' } }} - projects={{ foo: { name: 'foo' } }} - selected={false} - {...props} - /> - ); -} - jest.useFakeTimers(); it('renders selected', () => { - const wrapper = render(); + const wrapper = shallowRender(); expect(wrapper).toMatchSnapshot(); wrapper.setProps({ selected: true }); expect(wrapper).toMatchSnapshot(); @@ -56,7 +38,7 @@ it('renders match', () => { qualifier: 'TRK', organization: 'bar' }; - const wrapper = render({ component }); + const wrapper = shallowRender({ component }); expect(wrapper).toMatchSnapshot(); }); @@ -68,7 +50,7 @@ it('renders favorite', () => { qualifier: 'TRK', organization: 'bar' }; - const wrapper = render({ component }); + const wrapper = shallowRender({ component }); expect(wrapper).toMatchSnapshot(); }); @@ -80,7 +62,7 @@ it('renders recently browsed', () => { qualifier: 'TRK', organization: 'bar' }; - const wrapper = render({ component }); + const wrapper = shallowRender({ component }); expect(wrapper).toMatchSnapshot(); }); @@ -92,7 +74,7 @@ it('renders projects', () => { qualifier: 'BRC', project: 'foo' }; - const wrapper = render({ component }); + const wrapper = shallowRender({ component }); expect(wrapper).toMatchSnapshot(); }); @@ -104,14 +86,14 @@ it('renders organizations', () => { qualifier: 'TRK', organization: 'bar' }; - const wrapper = render({ appState: { organizationsEnabled: true }, component }); + const wrapper = shallowRender({ appState: { organizationsEnabled: true }, component }); expect(wrapper).toMatchSnapshot(); wrapper.setProps({ appState: { organizationsEnabled: false } }); expect(wrapper).toMatchSnapshot(); }); it('shows tooltip after delay', () => { - const wrapper = render(); + const wrapper = shallowRender(); expect(wrapper.find('Tooltip').prop('visible')).toBe(false); wrapper.setProps({ selected: true }); @@ -124,3 +106,19 @@ it('shows tooltip after delay', () => { wrapper.setProps({ selected: false }); expect(wrapper.find('Tooltip').prop('visible')).toBe(false); }); + +function shallowRender(props: Partial<SearchResult['props']> = {}) { + return shallow( + <SearchResult + appState={{ organizationsEnabled: false }} + component={{ key: 'foo', name: 'foo', qualifier: 'TRK', organization: 'bar' }} + innerRef={jest.fn()} + onClose={jest.fn()} + onSelect={jest.fn()} + organizations={{ bar: { name: 'bar' } }} + projects={{ foo: { name: 'foo' } }} + selected={false} + {...props} + /> + ); +} 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.tsx index 114bf2a67b6..f04fd8b5a58 100644 --- 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.tsx @@ -17,17 +17,15 @@ * 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 * as React from 'react'; import { shallow } from 'enzyme'; -import SearchResults from '../SearchResults'; +import SearchResults, { Props } from '../SearchResults'; it('renders different components and dividers between them', () => { expect( shallow( <SearchResults allowMore={true} - loadingMore={null} more={{}} onMoreClick={jest.fn()} onSelect={jest.fn()} @@ -38,7 +36,6 @@ it('renders different components and dividers between them', () => { BRC: [component('qwe', 'BRC'), component('qux', 'BRC')], FIL: [component('zux', 'FIL')] }} - selected={null} /> ) ).toMatchSnapshot(); @@ -49,7 +46,6 @@ it('renders "Show More" link', () => { shallow( <SearchResults allowMore={true} - loadingMore={null} more={{ TRK: 175, BRC: 0 }} onMoreClick={jest.fn()} onSelect={jest.fn()} @@ -59,12 +55,31 @@ it('renders "Show More" link', () => { TRK: [component('foo'), component('bar')], BRC: [component('qwe', 'BRC'), component('qux', 'BRC')] }} - selected={null} /> ) ).toMatchSnapshot(); }); -function component(key /*: string */, qualifier /*: string */ = 'TRK') { +it('should render no results', () => { + // eslint-disable-next-line react/display-name + expect(shallowRender({ renderNoResults: () => <div id="no-results" /> })).toMatchSnapshot(); +}); + +function component(key: string, qualifier = 'TRK') { return { key, name: key, qualifier }; } + +function shallowRender(props: Partial<Props> = {}) { + return shallow( + <SearchResults + allowMore={true} + more={{}} + onMoreClick={jest.fn()} + onSelect={jest.fn()} + renderNoResults={() => <div />} + renderResult={() => <div />} + results={{}} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/SearchShowMore-test.tsx b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchShowMore-test.tsx new file mode 100644 index 00000000000..20c94d36f64 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchShowMore-test.tsx @@ -0,0 +1,61 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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 * as React from 'react'; +import { shallow } from 'enzyme'; +import SearchShowMore from '../SearchShowMore'; +import { click } from '../../../../helpers/testUtils'; + +it('should render', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should trigger showing more', () => { + const onMoreClick = jest.fn(); + const wrapper = shallowRender({ onMoreClick }); + click(wrapper.find('a'), { + currentTarget: { + blur() {}, + dataset: { qualifier: 'TRK' }, + preventDefault() {}, + stopPropagation() {} + } + }); + expect(onMoreClick).toBeCalledWith('TRK'); +}); + +it('should select on mouse over', () => { + const onSelect = jest.fn(); + const wrapper = shallowRender({ onSelect }); + wrapper.find('a').simulate('mouseenter', { currentTarget: { dataset: { qualifier: 'TRK' } } }); + expect(onSelect).toBeCalledWith('qualifier###TRK'); +}); + +function shallowRender(props: Partial<SearchShowMore['props']> = {}) { + return shallow( + <SearchShowMore + allowMore={true} + onMoreClick={jest.fn()} + onSelect={jest.fn()} + qualifier="TRK" + selected={false} + {...props} + /> + ); +} 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.tsx.snap index 6541c673539..6541c673539 100644 --- 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.tsx.snap 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.tsx.snap index 0a9d6781bf0..0a9d6781bf0 100644 --- 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.tsx.snap 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.tsx.snap index f13cf142896..f49668c7822 100644 --- 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.tsx.snap @@ -23,7 +23,6 @@ exports[`renders "Show More" link 1`] = ` <SearchShowMore allowMore={true} key="more-TRK" - loadingMore={null} onMoreClick={[MockFunction]} onSelect={[MockFunction]} qualifier="TRK" @@ -109,3 +108,9 @@ exports[`renders different components and dividers between them 1`] = ` </span> </ul> `; + +exports[`should render no results 1`] = ` +<div + id="no-results" +/> +`; diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchShowMore-test.tsx.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchShowMore-test.tsx.snap new file mode 100644 index 00000000000..9a3d97e3982 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchShowMore-test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` +<li + className="menu-footer" + key="more-TRK" +> + <DeferredSpinner + className="navbar-search-icon" + loading={false} + timeout={100} + > + <a + className="" + data-qualifier="TRK" + href="#" + onClick={[Function]} + onMouseEnter={[Function]} + > + <div + className="pull-right text-muted-2 menu-footer-note" + dangerouslySetInnerHTML={ + Object { + "__html": "search.show_more.hint.<span class=\\"shortcut-button shortcut-button-small\\">Enter</span>", + } + } + /> + <span> + show_more + </span> + </a> + </DeferredSpinner> +</li> +`; 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.ts index 24e22cdf647..1a4d8a92135 100644 --- a/server/sonar-web/src/main/js/app/components/search/utils.js +++ b/server/sonar-web/src/main/js/app/components/search/utils.ts @@ -17,32 +17,29 @@ * 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', 'APP', 'TRK', 'BRC', 'FIL', 'UTS']; -export function sortQualifiers(qualifiers /*: Array<string> */) { +export function sortQualifiers(qualifiers: 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 interface ComponentResult { + isFavorite?: boolean; + isRecentlyBrowsed?: boolean; + key: string; + match?: string; + name: string; + organization?: string; + project?: string; + qualifier: string; +} -/*:: -export type Results = { [qualifier: string]: Array<Component> }; -*/ +export interface Results { + [qualifier: string]: ComponentResult[]; +} -/*:: -export type More = { [string]: number }; -*/ +export interface More { + [qualifier: string]: number; +} |