]> source.dussan.org Git - sonarqube.git/commitdiff
rewrite global search in ts (#680)
authorStas Vilchik <stas.vilchik@sonarsource.com>
Wed, 5 Sep 2018 11:51:44 +0000 (13:51 +0200)
committerSonarTech <sonartech@sonarsource.com>
Wed, 5 Sep 2018 18:21:03 +0000 (20:21 +0200)
29 files changed:
server/sonar-web/src/main/js/app/components/RecentHistory.js [deleted file]
server/sonar-web/src/main/js/app/components/RecentHistory.ts [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/__tests__/RecentHistory-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap
server/sonar-web/src/main/js/app/components/search/Search.d.ts [deleted file]
server/sonar-web/src/main/js/app/components/search/Search.js [deleted file]
server/sonar-web/src/main/js/app/components/search/Search.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/SearchResult.js [deleted file]
server/sonar-web/src/main/js/app/components/search/SearchResult.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/SearchResults.js [deleted file]
server/sonar-web/src/main/js/app/components/search/SearchResults.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/SearchShowMore.js [deleted file]
server/sonar-web/src/main/js/app/components/search/SearchShowMore.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.js [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.js [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/__tests__/SearchShowMore-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.js.snap [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.js.snap [deleted file]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchShowMore-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/utils.js [deleted file]
server/sonar-web/src/main/js/app/components/search/utils.ts [new file with mode: 0644]

diff --git a/server/sonar-web/src/main/js/app/components/RecentHistory.js b/server/sonar-web/src/main/js/app/components/RecentHistory.js
deleted file mode 100644 (file)
index 0967b38..0000000
+++ /dev/null
@@ -1,77 +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 { 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 default class RecentHistory {
-  static get() /*: History */ {
-    const history = get(RECENT_HISTORY);
-    if (history == null) {
-      return [];
-    } else {
-      try {
-        return JSON.parse(history);
-      } catch (e) {
-        remove(RECENT_HISTORY);
-        return [];
-      }
-    }
-  }
-
-  static set(newHistory /*: History */) /*: void */ {
-    save(RECENT_HISTORY, JSON.stringify(newHistory));
-  }
-
-  static clear() /*: void */ {
-    remove(RECENT_HISTORY);
-  }
-
-  static add(
-    componentKey /*: string */,
-    componentName /*: string */,
-    icon /*: string */,
-    organization /*: string | void */
-  ) /*: void */ {
-    const sonarHistory = RecentHistory.get();
-    const newEntry = { key: componentKey, name: componentName, icon, organization };
-    let newHistory = sonarHistory.filter(entry => entry.key !== newEntry.key);
-    newHistory.unshift(newEntry);
-    newHistory = newHistory.slice(0, HISTORY_LIMIT);
-    RecentHistory.set(newHistory);
-  }
-
-  static remove(componentKey /*: string */) /*: void */ {
-    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/RecentHistory.ts b/server/sonar-web/src/main/js/app/components/RecentHistory.ts
new file mode 100644 (file)
index 0000000..4d10570
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * 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 { get, remove, save } from '../../helpers/storage';
+
+const RECENT_HISTORY = 'sonar_recent_history';
+const HISTORY_LIMIT = 10;
+
+export type History = Array<{
+  key: string;
+  name: string;
+  icon: string;
+  organization?: string;
+}>;
+
+export default class RecentHistory {
+  static get(): History {
+    const history = get(RECENT_HISTORY);
+    if (history == null) {
+      return [];
+    } else {
+      try {
+        return JSON.parse(history);
+      } catch {
+        remove(RECENT_HISTORY);
+        return [];
+      }
+    }
+  }
+
+  static set(newHistory: History) {
+    save(RECENT_HISTORY, JSON.stringify(newHistory));
+  }
+
+  static clear() {
+    remove(RECENT_HISTORY);
+  }
+
+  static add(
+    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);
+    newHistory.unshift(newEntry);
+    newHistory = newHistory.slice(0, HISTORY_LIMIT);
+    RecentHistory.set(newHistory);
+  }
+
+  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 (file)
index 0000000..8898e89
--- /dev/null
@@ -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)])
+  );
+});
index 1ffdab8f252b27f874930b15f605b0237e3140e5..6598367a9ccd835b487aaa944063b0ccf382a57a 100644 (file)
@@ -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 (file)
index 58ceb74..0000000
+++ /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.js
deleted file mode 100644 (file)
index b65be42..0000000
+++ /dev/null
@@ -1,409 +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 PropTypes from 'prop-types';
-import 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 RecentHistory from '../RecentHistory';
-import DeferredSpinner from '../../../components/common/DeferredSpinner';
-import { DropdownOverlay } from '../../../components/controls/Dropdown';
-import ClockIcon from '../../../components/icons-components/ClockIcon';
-import OutsideClickHandler from '../../../components/controls/OutsideClickHandler';
-import SearchBox from '../../../components/controls/SearchBox';
-import { lazyLoad } from '../../../components/lazyLoad';
-import { getSuggestions } from '../../../api/components';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { scrollToElement } from '../../../helpers/scrolling';
-import { getProjectUrl } from '../../../helpers/urls';
-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
-  };
-
-  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', () => {
-      if (this.input) {
-        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');
-  }
-
-  handleClickOutside = () => {
-    this.closeSearch(false);
-  };
-
-  handleFocus = () => {
-    if (!this.state.open) {
-      // simulate click to close any other dropdowns
-      const body = document.documentElement;
-      if (body) {
-        body.click();
-      }
-    }
-    this.openSearch();
-  };
-
-  openSearch = () => {
-    if (!this.state.open && !this.state.query) {
-      this.search('');
-    }
-    this.setState({ open: true });
-  };
-
-  closeSearch = (clear /*: boolean */ = true) => {
-    if (this.input) {
-      this.input.blur();
-    }
-    this.setState(
-      clear
-        ? {
-            more: {},
-            open: false,
-            organizations: {},
-            projects: {},
-            query: '',
-            results: {},
-            selected: null,
-            shortQuery: false
-          }
-        : {
-            open: false
-          }
-    );
-  };
-
-  getPlainComponentsList = (results /*: Results */, more /*: More */) =>
-    sortQualifiers(Object.keys(results)).reduce((components, qualifier) => {
-      const next = [...components, ...results[qualifier].map(component => component.key)];
-      if (more[qualifier]) {
-        next.push('qualifier###' + qualifier);
-      }
-      return next;
-    }, []);
-
-  mergeWithRecentlyBrowsed = (components /*: Array<Component> */) => {
-    const recentlyBrowsed = RecentHistory.get().map(component => ({
-      ...component,
-      isRecentlyBrowsed: true,
-      qualifier: component.icon.toUpperCase()
-    }));
-    return uniqBy([...components, ...recentlyBrowsed], 'key');
-  };
-
-  stopLoading = () => {
-    if (this.mounted) {
-      this.setState({ loading: false });
-    }
-  };
-
-  search = (query /*: string */) => {
-    if (query.length === 0 || query.length >= 2) {
-      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: query.length > 2 && response.warning === 'short_input'
-          }));
-        }
-      }, this.stopLoading);
-    } else {
-      this.setState({ loading: false });
-    }
-  };
-
-  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);
-    }
-  };
-
-  handleQueryChange = (query /*: string */) => {
-    this.setState({ query, shortQuery: query.length === 1 });
-    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 38:
-        event.preventDefault();
-        this.selectPrevious();
-        return;
-      case 40:
-        event.preventDefault();
-        this.selectNext();
-        // keep this return to prevent fall-through in case more cases will be adder later
-        // eslint-disable-next-line no-useless-return
-        return;
-    }
-  };
-
-  handleSelect = (selected /*: string */) => {
-    this.setState({ selected });
-  };
-
-  innerRef = (component /*: string */, node /*: HTMLElement */) => {
-    this.nodes[component] = node;
-  };
-
-  searchInputRef = (node /*: HTMLInputElement | null */) => {
-    this.input = node;
-  };
-
-  renderResult = (component /*: Component */) => (
-    <SearchResult
-      appState={this.props.appState}
-      component={component}
-      innerRef={this.innerRef}
-      key={component.key}
-      onClose={this.closeSearch}
-      onSelect={this.handleSelect}
-      organizations={this.state.organizations}
-      projects={this.state.projects}
-      selected={this.state.selected === component.key}
-    />
-  );
-
-  renderNoResults = () => (
-    <div className="navbar-search-no-results">
-      {translateWithParameters('no_results_for_x', this.state.query)}
-    </div>
-  );
-
-  render() {
-    const search = (
-      <li className="navbar-search dropdown">
-        <DeferredSpinner className="navbar-search-icon" loading={this.state.loading} />
-
-        <SearchBox
-          autoFocus={this.state.open}
-          innerRef={this.searchInputRef}
-          minLength={2}
-          onChange={this.handleQueryChange}
-          onFocus={this.handleFocus}
-          onKeyDown={this.handleKeyDown}
-          placeholder={translate('search.placeholder')}
-          value={this.state.query}
-        />
-
-        {this.state.shortQuery && (
-          <span className="navbar-search-input-hint">
-            {translateWithParameters('select2.tooShort', 2)}
-          </span>
-        )}
-
-        {this.state.open &&
-          Object.keys(this.state.results).length > 0 && (
-            <DropdownOverlay noPadding={true}>
-              <div className="global-navbar-search-dropdown" ref={node => (this.node = node)}>
-                <SearchResults
-                  allowMore={this.state.query.length !== 1}
-                  loadingMore={this.state.loadingMore}
-                  more={this.state.more}
-                  onMoreClick={this.searchMore}
-                  onSelect={this.handleSelect}
-                  renderNoResults={this.renderNoResults}
-                  renderResult={this.renderResult}
-                  results={this.state.results}
-                  selected={this.state.selected}
-                />
-                <div className="dropdown-bottom-hint">
-                  <div className="pull-right">
-                    <ClockIcon className="little-spacer-right" size={12} />
-                    {translate('recently_browsed')}
-                  </div>
-                  <FormattedMessage
-                    defaultMessage={translate('search.shortcut_hint')}
-                    id="search.shortcut_hint"
-                    values={{
-                      shortcut: <span className="shortcut-button shortcut-button-small">s</span>
-                    }}
-                  />
-                </div>
-              </div>
-            </DropdownOverlay>
-          )}
-      </li>
-    );
-
-    return this.state.open ? (
-      <OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
-    ) : (
-      search
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/Search.tsx b/server/sonar-web/src/main/js/app/components/search/Search.tsx
new file mode 100644 (file)
index 0000000..9d91d8b
--- /dev/null
@@ -0,0 +1,408 @@
+/*
+ * 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 * as key from 'keymaster';
+import { debounce, keyBy, uniqBy } from 'lodash';
+import { FormattedMessage } from 'react-intl';
+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';
+import ClockIcon from '../../../components/icons-components/ClockIcon';
+import OutsideClickHandler from '../../../components/controls/OutsideClickHandler';
+import SearchBox from '../../../components/controls/SearchBox';
+import { lazyLoad } from '../../../components/lazyLoad';
+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'));
+
+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;
+}
+
+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,
+      more: {},
+      open: false,
+      organizations: {},
+      projects: {},
+      query: '',
+      results: {},
+      shortQuery: false
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    key('s', () => {
+      this.focusInput();
+      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');
+  }
+
+  focusInput = () => {
+    if (this.input) {
+      this.input.focus();
+    }
+  };
+
+  handleClickOutside = () => {
+    this.closeSearch(false);
+  };
+
+  handleFocus = () => {
+    if (!this.state.open) {
+      // simulate click to close any other dropdowns
+      const body = document.documentElement;
+      if (body) {
+        body.click();
+      }
+    }
+    this.openSearch();
+  };
+
+  openSearch = () => {
+    if (!this.state.open && !this.state.query) {
+      this.search('');
+    }
+    this.setState({ open: true });
+  };
+
+  closeSearch = (clear = true) => {
+    if (this.input) {
+      this.input.blur();
+    }
+    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) =>
+    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: ComponentResult[]) => {
+    const recentlyBrowsed = RecentHistory.get().map(component => ({
+      ...component,
+      isRecentlyBrowsed: true,
+      qualifier: component.icon.toUpperCase()
+    }));
+    return uniqBy([...components, ...recentlyBrowsed], 'key');
+  };
+
+  stopLoading = () => {
+    if (this.mounted) {
+      this.setState({ loading: false });
+    }
+  };
+
+  search = (query: string) => {
+    if (query.length === 0 || query.length >= 2) {
+      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: Results = {};
+          const more: 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] : undefined,
+            shortQuery: query.length > 2 && response.warning === 'short_input'
+          }));
+        }
+      }, this.stopLoading);
+    } else {
+      this.setState({ loading: false });
+    }
+  };
+
+  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) => {
+    this.setState({ query, shortQuery: query.length === 1 });
+    this.search(query);
+  };
+
+  selectPrevious = () => {
+    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] } : null;
+      } else {
+        return null;
+      }
+    });
+  };
+
+  selectNext = () => {
+    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] } : null;
+      } else {
+        return null;
+      }
+    });
+  };
+
+  openSelected = () => {
+    const { selected } = this.state;
+    if (selected) {
+      if (selected.startsWith('qualifier###')) {
+        this.searchMore(selected.substr(12));
+      } else {
+        this.props.router.push(getProjectUrl(selected));
+        this.closeSearch();
+      }
+    }
+  };
+
+  scrollToSelected = () => {
+    if (this.state.selected) {
+      const node = this.nodes[this.state.selected];
+      if (node && this.node) {
+        scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node });
+      }
+    }
+  };
+
+  handleKeyDown = (event: React.KeyboardEvent) => {
+    switch (event.keyCode) {
+      case 13:
+        event.preventDefault();
+        this.openSelected();
+        return;
+      case 38:
+        event.preventDefault();
+        this.selectPrevious();
+        return;
+      case 40:
+        event.preventDefault();
+        this.selectNext();
+        // keep this return to prevent fall-through in case more cases will be adder later
+        // eslint-disable-next-line no-useless-return
+        return;
+    }
+  };
+
+  handleSelect = (selected: string) => {
+    this.setState({ selected });
+  };
+
+  innerRef = (component: string, node: HTMLElement | null) => {
+    if (node) {
+      this.nodes[component] = node;
+    }
+  };
+
+  searchInputRef = (node: HTMLInputElement | null) => {
+    this.input = node;
+  };
+
+  renderResult = (component: ComponentResult) => (
+    <SearchResult
+      appState={this.props.appState}
+      component={component}
+      innerRef={this.innerRef}
+      key={component.key}
+      onClose={this.closeSearch}
+      onSelect={this.handleSelect}
+      organizations={this.state.organizations}
+      projects={this.state.projects}
+      selected={this.state.selected === component.key}
+    />
+  );
+
+  renderNoResults = () => (
+    <div className="navbar-search-no-results">
+      {translateWithParameters('no_results_for_x', this.state.query)}
+    </div>
+  );
+
+  render() {
+    const search = (
+      <li className="navbar-search dropdown">
+        <DeferredSpinner className="navbar-search-icon" loading={this.state.loading} />
+
+        <SearchBox
+          autoFocus={this.state.open}
+          innerRef={this.searchInputRef}
+          minLength={2}
+          onChange={this.handleQueryChange}
+          onFocus={this.handleFocus}
+          onKeyDown={this.handleKeyDown}
+          placeholder={translate('search.placeholder')}
+          value={this.state.query}
+        />
+
+        {this.state.shortQuery && (
+          <span className="navbar-search-input-hint">
+            {translateWithParameters('select2.tooShort', 2)}
+          </span>
+        )}
+
+        {this.state.open &&
+          Object.keys(this.state.results).length > 0 && (
+            <DropdownOverlay noPadding={true}>
+              <div className="global-navbar-search-dropdown" ref={node => (this.node = node)}>
+                <SearchResults
+                  allowMore={this.state.query.length !== 1}
+                  loadingMore={this.state.loadingMore}
+                  more={this.state.more}
+                  onMoreClick={this.searchMore}
+                  onSelect={this.handleSelect}
+                  renderNoResults={this.renderNoResults}
+                  renderResult={this.renderResult}
+                  results={this.state.results}
+                  selected={this.state.selected}
+                />
+                <div className="dropdown-bottom-hint">
+                  <div className="pull-right">
+                    <ClockIcon className="little-spacer-right" size={12} />
+                    {translate('recently_browsed')}
+                  </div>
+                  <FormattedMessage
+                    defaultMessage={translate('search.shortcut_hint')}
+                    id="search.shortcut_hint"
+                    values={{
+                      shortcut: <span className="shortcut-button shortcut-button-small">s</span>
+                    }}
+                  />
+                </div>
+              </div>
+            </DropdownOverlay>
+          )}
+      </li>
+    );
+
+    return this.state.open ? (
+      <OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
+    ) : (
+      search
+    );
+  }
+}
+
+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.js
deleted file mode 100644 (file)
index c8426ea..0000000
+++ /dev/null
@@ -1,159 +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 { Link } from 'react-router';
-/*:: import type { Component } 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';
-
-/*::
-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
-};
-*/
-
-const TOOLTIP_DELAY = 1000;
-
-export default class SearchResult extends React.PureComponent {
-  /*:: interval: ?number; */
-  /*:: props: Props; */
-  state /*: State */ = { tooltipVisible: false };
-
-  componentDidMount() {
-    if (this.props.selected) {
-      this.scheduleTooltip();
-    }
-  }
-
-  componentWillReceiveProps(nextProps /*: Props */) {
-    if (!this.props.selected && nextProps.selected) {
-      this.scheduleTooltip();
-    } else if (this.props.selected && !nextProps.selected) {
-      this.unscheduleTooltip();
-      this.setState({ tooltipVisible: false });
-    }
-  }
-
-  componentWillUnmount() {
-    this.unscheduleTooltip();
-  }
-
-  scheduleTooltip = () => {
-    this.interval = setTimeout(() => this.setState({ tooltipVisible: true }), TOOLTIP_DELAY);
-  };
-
-  unscheduleTooltip = () => {
-    if (this.interval) {
-      clearInterval(this.interval);
-    }
-  };
-
-  handleMouseEnter = () => {
-    this.props.onSelect(this.props.component.key);
-  };
-
-  renderOrganization = (component /*: Component */) => {
-    if (!this.props.appState.organizationsEnabled) {
-      return null;
-    }
-
-    if (
-      !['VW', 'SVW', 'APP', 'TRK'].includes(component.qualifier) ||
-      component.organization == null
-    ) {
-      return null;
-    }
-
-    const organization = this.props.organizations[component.organization];
-    return organization ? (
-      <div className="navbar-search-item-right text-muted-2">{organization.name}</div>
-    ) : 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 ? (
-      <div className="navbar-search-item-right text-muted-2">{project.name}</div>
-    ) : null;
-  };
-
-  render() {
-    const { component } = this.props;
-
-    return (
-      <li
-        className={this.props.selected ? 'active' : undefined}
-        key={component.key}
-        ref={node => this.props.innerRef(component.key, node)}>
-        <Tooltip
-          mouseEnterDelay={TOOLTIP_DELAY / 1000}
-          overlay={component.key}
-          placement="left"
-          visible={this.state.tooltipVisible}>
-          <Link
-            className="navbar-search-item-link"
-            data-key={component.key}
-            onClick={this.props.onClose}
-            onMouseEnter={this.handleMouseEnter}
-            to={getProjectUrl(component.key)}>
-            <span className="navbar-search-item-icons little-spacer-right">
-              {component.isFavorite && <FavoriteIcon favorite={true} size={12} />}
-              {!component.isFavorite && component.isRecentlyBrowsed && <ClockIcon size={12} />}
-              <QualifierIcon className="little-spacer-right" qualifier={component.qualifier} />
-            </span>
-
-            {component.match ? (
-              <span
-                className="navbar-search-item-match"
-                dangerouslySetInnerHTML={{ __html: component.match }}
-              />
-            ) : (
-              <span className="navbar-search-item-match">{component.name}</span>
-            )}
-
-            {this.renderOrganization(component)}
-            {this.renderProject(component)}
-          </Link>
-        </Tooltip>
-      </li>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/SearchResult.tsx b/server/sonar-web/src/main/js/app/components/search/SearchResult.tsx
new file mode 100644 (file)
index 0000000..7dd26bd
--- /dev/null
@@ -0,0 +1,153 @@
+/*
+ * 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 { Link } from 'react-router';
+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;
+}
+
+interface State {
+  tooltipVisible: boolean;
+}
+
+const TOOLTIP_DELAY = 1000;
+
+export default class SearchResult extends React.PureComponent<Props, State> {
+  interval?: number;
+  state: State = { tooltipVisible: false };
+
+  componentDidMount() {
+    if (this.props.selected) {
+      this.scheduleTooltip();
+    }
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    if (!this.props.selected && nextProps.selected) {
+      this.scheduleTooltip();
+    } else if (this.props.selected && !nextProps.selected) {
+      this.unscheduleTooltip();
+      this.setState({ tooltipVisible: false });
+    }
+  }
+
+  componentWillUnmount() {
+    this.unscheduleTooltip();
+  }
+
+  scheduleTooltip = () => {
+    this.interval = window.setTimeout(() => {
+      this.setState({ tooltipVisible: true });
+    }, TOOLTIP_DELAY);
+  };
+
+  unscheduleTooltip = () => {
+    if (this.interval) {
+      window.clearInterval(this.interval);
+    }
+  };
+
+  handleMouseEnter = () => {
+    this.props.onSelect(this.props.component.key);
+  };
+
+  renderOrganization = (component: ComponentResult) => {
+    if (!this.props.appState.organizationsEnabled) {
+      return null;
+    }
+
+    if (!['VW', 'SVW', 'APP', 'TRK'].includes(component.qualifier) || !component.organization) {
+      return null;
+    }
+
+    const organization = this.props.organizations[component.organization];
+    return organization ? (
+      <div className="navbar-search-item-right text-muted-2">{organization.name}</div>
+    ) : null;
+  };
+
+  renderProject = (component: ComponentResult) => {
+    if (!['BRC', 'FIL', 'UTS'].includes(component.qualifier) || component.project == null) {
+      return null;
+    }
+
+    const project = this.props.projects[component.project];
+    return project ? (
+      <div className="navbar-search-item-right text-muted-2">{project.name}</div>
+    ) : null;
+  };
+
+  render() {
+    const { component } = this.props;
+
+    return (
+      <li
+        className={this.props.selected ? 'active' : undefined}
+        key={component.key}
+        ref={node => this.props.innerRef(component.key, node)}>
+        <Tooltip
+          mouseEnterDelay={TOOLTIP_DELAY / 1000}
+          overlay={component.key}
+          placement="left"
+          visible={this.state.tooltipVisible}>
+          <Link
+            className="navbar-search-item-link"
+            data-key={component.key}
+            onClick={this.props.onClose}
+            onMouseEnter={this.handleMouseEnter}
+            to={getProjectUrl(component.key)}>
+            <span className="navbar-search-item-icons little-spacer-right">
+              {component.isFavorite && <FavoriteIcon favorite={true} size={12} />}
+              {!component.isFavorite && component.isRecentlyBrowsed && <ClockIcon size={12} />}
+              <QualifierIcon className="little-spacer-right" qualifier={component.qualifier} />
+            </span>
+
+            {component.match ? (
+              <span
+                className="navbar-search-item-match"
+                dangerouslySetInnerHTML={{ __html: component.match }}
+              />
+            ) : (
+              <span className="navbar-search-item-match">{component.name}</span>
+            )}
+
+            {this.renderOrganization(component)}
+            {this.renderProject(component)}
+          </Link>
+        </Tooltip>
+      </li>
+    );
+  }
+}
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 (file)
index 2d8e3b7..0000000
+++ /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 (file)
index 0000000..0c02e8f
--- /dev/null
@@ -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.js
deleted file mode 100644 (file)
index a05ea9b..0000000
+++ /dev/null
@@ -1,80 +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 classNames from 'classnames';
-import DeferredSpinner from '../../../components/common/DeferredSpinner';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-
-/*::
-type Props = {|
-  allowMore: boolean,
-  loadingMore: ?string,
-  onMoreClick: string => void,
-  onSelect: string => void,
-  qualifier: string,
-  selected: boolean
-|};
-*/
-
-export default class SearchShowMore extends React.PureComponent {
-  /*:: props: Props; */
-
-  handleMoreClick = (event /*: MouseEvent & { currentTarget: HTMLElement } */) => {
-    event.preventDefault();
-    event.stopPropagation();
-    event.currentTarget.blur();
-    const { qualifier } = event.currentTarget.dataset;
-    this.props.onMoreClick(qualifier);
-  };
-
-  handleMoreMouseEnter = (event /*: { currentTarget: HTMLElement } */) => {
-    const { qualifier } = event.currentTarget.dataset;
-    this.props.onSelect(`qualifier###${qualifier}`);
-  };
-
-  render() {
-    const { loadingMore, qualifier, selected } = this.props;
-
-    return (
-      <li className={classNames('menu-footer', { active: selected })} key={`more-${qualifier}`}>
-        <DeferredSpinner className="navbar-search-icon" loading={loadingMore === qualifier}>
-          <a
-            className={classNames({ 'cursor-not-allowed': !this.props.allowMore })}
-            data-qualifier={qualifier}
-            href="#"
-            onClick={this.handleMoreClick}
-            onMouseEnter={this.handleMoreMouseEnter}>
-            <div
-              className="pull-right text-muted-2 menu-footer-note"
-              dangerouslySetInnerHTML={{
-                __html: translateWithParameters(
-                  'search.show_more.hint',
-                  '<span class="shortcut-button shortcut-button-small">Enter</span>'
-                )
-              }}
-            />
-            <span>{translate('show_more')}</span>
-          </a>
-        </DeferredSpinner>
-      </li>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/SearchShowMore.tsx b/server/sonar-web/src/main/js/app/components/search/SearchShowMore.tsx
new file mode 100644 (file)
index 0000000..7b1d9eb
--- /dev/null
@@ -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 * as classNames from 'classnames';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+
+interface Props {
+  allowMore: boolean;
+  loadingMore?: string;
+  onMoreClick: (qualifier: string) => void;
+  onSelect: (qualifier: string) => void;
+  qualifier: string;
+  selected: boolean;
+}
+
+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;
+    if (qualifier) {
+      this.props.onMoreClick(qualifier);
+    }
+  };
+
+  handleMoreMouseEnter = (event: React.MouseEvent<HTMLAnchorElement>) => {
+    const { qualifier } = event.currentTarget.dataset;
+    if (qualifier) {
+      this.props.onSelect(`qualifier###${qualifier}`);
+    }
+  };
+
+  render() {
+    const { loadingMore, qualifier, selected } = this.props;
+
+    return (
+      <li className={classNames('menu-footer', { active: selected })} key={`more-${qualifier}`}>
+        <DeferredSpinner className="navbar-search-icon" loading={loadingMore === qualifier}>
+          <a
+            className={classNames({ 'cursor-not-allowed': !this.props.allowMore })}
+            data-qualifier={qualifier}
+            href="#"
+            onClick={this.handleMoreClick}
+            onMouseEnter={this.handleMoreMouseEnter}>
+            <div
+              className="pull-right text-muted-2 menu-footer-note"
+              dangerouslySetInnerHTML={{
+                __html: translateWithParameters(
+                  'search.show_more.hint',
+                  '<span class="shortcut-button shortcut-button-small">Enter</span>'
+                )
+              }}
+            />
+            <span>{translate('show_more')}</span>
+          </a>
+        </DeferredSpinner>
+      </li>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js b/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js
deleted file mode 100644 (file)
index 01168e7..0000000
+++ /dev/null
@@ -1,96 +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 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);
-}
-
-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('SearchBox'), 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();
-});
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.tsx b/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.tsx
new file mode 100644 (file)
index 0000000..a3e6645
--- /dev/null
@@ -0,0 +1,96 @@
+/*
+ * 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, ShallowWrapper } from 'enzyme';
+import { Search } from '../Search';
+import { elementKeydown } from '../../../../helpers/testUtils';
+
+it('selects results', () => {
+  const form = shallowRender();
+  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 = shallowRender();
+  form.setState({
+    open: true,
+    results: { TRK: [component('foo')] },
+    selected: 'foo'
+  });
+  const openSelected = jest.fn();
+  (form.instance() as Search).openSelected = openSelected;
+  elementKeydown(form.find('SearchBox'), 13);
+  expect(openSelected).toBeCalled();
+});
+
+it('shows warning about short input', () => {
+  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.js
deleted file mode 100644 (file)
index 91ca09f..0000000
+++ /dev/null
@@ -1,126 +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 { 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();
-  expect(wrapper).toMatchSnapshot();
-  wrapper.setProps({ selected: true });
-  expect(wrapper).toMatchSnapshot();
-});
-
-it('renders match', () => {
-  const component = {
-    key: 'foo',
-    name: 'foo',
-    match: 'f<mark>o</mark>o',
-    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();
-});
-
-it('shows tooltip after delay', () => {
-  const wrapper = render();
-  expect(wrapper.find('Tooltip').prop('visible')).toBe(false);
-
-  wrapper.setProps({ selected: true });
-  expect(wrapper.find('Tooltip').prop('visible')).toBe(false);
-
-  jest.runAllTimers();
-  wrapper.update();
-  expect(wrapper.find('Tooltip').prop('visible')).toBe(true);
-
-  wrapper.setProps({ selected: false });
-  expect(wrapper.find('Tooltip').prop('visible')).toBe(false);
-});
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.tsx b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.tsx
new file mode 100644 (file)
index 0000000..83cfe81
--- /dev/null
@@ -0,0 +1,124 @@
+/*
+ * 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 SearchResult from '../SearchResult';
+
+jest.useFakeTimers();
+
+it('renders selected', () => {
+  const wrapper = shallowRender();
+  expect(wrapper).toMatchSnapshot();
+  wrapper.setProps({ selected: true });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('renders match', () => {
+  const component = {
+    key: 'foo',
+    name: 'foo',
+    match: 'f<mark>o</mark>o',
+    qualifier: 'TRK',
+    organization: 'bar'
+  };
+  const wrapper = shallowRender({ component });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('renders favorite', () => {
+  const component = {
+    isFavorite: true,
+    key: 'foo',
+    name: 'foo',
+    qualifier: 'TRK',
+    organization: 'bar'
+  };
+  const wrapper = shallowRender({ component });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('renders recently browsed', () => {
+  const component = {
+    isRecentlyBrowsed: true,
+    key: 'foo',
+    name: 'foo',
+    qualifier: 'TRK',
+    organization: 'bar'
+  };
+  const wrapper = shallowRender({ component });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('renders projects', () => {
+  const component = {
+    isRecentlyBrowsed: true,
+    key: 'qwe',
+    name: 'qwe',
+    qualifier: 'BRC',
+    project: 'foo'
+  };
+  const wrapper = shallowRender({ component });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('renders organizations', () => {
+  const component = {
+    isRecentlyBrowsed: true,
+    key: 'foo',
+    name: 'foo',
+    qualifier: 'TRK',
+    organization: 'bar'
+  };
+  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 = shallowRender();
+  expect(wrapper.find('Tooltip').prop('visible')).toBe(false);
+
+  wrapper.setProps({ selected: true });
+  expect(wrapper.find('Tooltip').prop('visible')).toBe(false);
+
+  jest.runAllTimers();
+  wrapper.update();
+  expect(wrapper.find('Tooltip').prop('visible')).toBe(true);
+
+  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.js
deleted file mode 100644 (file)
index 114bf2a..0000000
+++ /dev/null
@@ -1,70 +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 { shallow } from 'enzyme';
-import SearchResults from '../SearchResults';
-
-it('renders different components and dividers between them', () => {
-  expect(
-    shallow(
-      <SearchResults
-        allowMore={true}
-        loadingMore={null}
-        more={{}}
-        onMoreClick={jest.fn()}
-        onSelect={jest.fn()}
-        renderNoResults={() => <div />}
-        renderResult={component => <span key={component.key}>{component.name}</span>}
-        results={{
-          TRK: [component('foo'), component('bar')],
-          BRC: [component('qwe', 'BRC'), component('qux', 'BRC')],
-          FIL: [component('zux', 'FIL')]
-        }}
-        selected={null}
-      />
-    )
-  ).toMatchSnapshot();
-});
-
-it('renders "Show More" link', () => {
-  expect(
-    shallow(
-      <SearchResults
-        allowMore={true}
-        loadingMore={null}
-        more={{ TRK: 175, BRC: 0 }}
-        onMoreClick={jest.fn()}
-        onSelect={jest.fn()}
-        renderNoResults={() => <div />}
-        renderResult={component => <span key={component.key}>{component.name}</span>}
-        results={{
-          TRK: [component('foo'), component('bar')],
-          BRC: [component('qwe', 'BRC'), component('qux', 'BRC')]
-        }}
-        selected={null}
-      />
-    )
-  ).toMatchSnapshot();
-});
-
-function component(key /*: string */, qualifier /*: string */ = 'TRK') {
-  return { key, name: key, qualifier };
-}
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.tsx b/server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.tsx
new file mode 100644 (file)
index 0000000..f04fd8b
--- /dev/null
@@ -0,0 +1,85 @@
+/*
+ * 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 SearchResults, { Props } from '../SearchResults';
+
+it('renders different components and dividers between them', () => {
+  expect(
+    shallow(
+      <SearchResults
+        allowMore={true}
+        more={{}}
+        onMoreClick={jest.fn()}
+        onSelect={jest.fn()}
+        renderNoResults={() => <div />}
+        renderResult={component => <span key={component.key}>{component.name}</span>}
+        results={{
+          TRK: [component('foo'), component('bar')],
+          BRC: [component('qwe', 'BRC'), component('qux', 'BRC')],
+          FIL: [component('zux', 'FIL')]
+        }}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('renders "Show More" link', () => {
+  expect(
+    shallow(
+      <SearchResults
+        allowMore={true}
+        more={{ TRK: 175, BRC: 0 }}
+        onMoreClick={jest.fn()}
+        onSelect={jest.fn()}
+        renderNoResults={() => <div />}
+        renderResult={component => <span key={component.key}>{component.name}</span>}
+        results={{
+          TRK: [component('foo'), component('bar')],
+          BRC: [component('qwe', 'BRC'), component('qux', 'BRC')]
+        }}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+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 (file)
index 0000000..20c94d3
--- /dev/null
@@ -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.js.snap
deleted file mode 100644 (file)
index 6541c67..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`shows warning about short input 1`] = `
-<span
-  className="navbar-search-input-hint"
->
-  select2.tooShort.2
-</span>
-`;
-
-exports[`shows warning about short input 2`] = `
-<span
-  className="navbar-search-input-hint"
->
-  select2.tooShort.2
-</span>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.tsx.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.tsx.snap
new file mode 100644 (file)
index 0000000..6541c67
--- /dev/null
@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`shows warning about short input 1`] = `
+<span
+  className="navbar-search-input-hint"
+>
+  select2.tooShort.2
+</span>
+`;
+
+exports[`shows warning about short input 2`] = `
+<span
+  className="navbar-search-input-hint"
+>
+  select2.tooShort.2
+</span>
+`;
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
deleted file mode 100644 (file)
index 0a9d678..0000000
+++ /dev/null
@@ -1,391 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders favorite 1`] = `
-<li
-  key="foo"
->
-  <Tooltip
-    mouseEnterDelay={1}
-    overlay="foo"
-    placement="left"
-    visible={false}
-  >
-    <Link
-      className="navbar-search-item-link"
-      data-key="foo"
-      onClick={[MockFunction]}
-      onMouseEnter={[Function]}
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/dashboard",
-          "query": Object {
-            "branch": undefined,
-            "id": "foo",
-          },
-        }
-      }
-    >
-      <span
-        className="navbar-search-item-icons little-spacer-right"
-      >
-        <FavoriteIcon
-          favorite={true}
-          size={12}
-        />
-        <QualifierIcon
-          className="little-spacer-right"
-          qualifier="TRK"
-        />
-      </span>
-      <span
-        className="navbar-search-item-match"
-      >
-        foo
-      </span>
-    </Link>
-  </Tooltip>
-</li>
-`;
-
-exports[`renders match 1`] = `
-<li
-  key="foo"
->
-  <Tooltip
-    mouseEnterDelay={1}
-    overlay="foo"
-    placement="left"
-    visible={false}
-  >
-    <Link
-      className="navbar-search-item-link"
-      data-key="foo"
-      onClick={[MockFunction]}
-      onMouseEnter={[Function]}
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/dashboard",
-          "query": Object {
-            "branch": undefined,
-            "id": "foo",
-          },
-        }
-      }
-    >
-      <span
-        className="navbar-search-item-icons little-spacer-right"
-      >
-        <QualifierIcon
-          className="little-spacer-right"
-          qualifier="TRK"
-        />
-      </span>
-      <span
-        className="navbar-search-item-match"
-        dangerouslySetInnerHTML={
-          Object {
-            "__html": "f<mark>o</mark>o",
-          }
-        }
-      />
-    </Link>
-  </Tooltip>
-</li>
-`;
-
-exports[`renders organizations 1`] = `
-<li
-  key="foo"
->
-  <Tooltip
-    mouseEnterDelay={1}
-    overlay="foo"
-    placement="left"
-    visible={false}
-  >
-    <Link
-      className="navbar-search-item-link"
-      data-key="foo"
-      onClick={[MockFunction]}
-      onMouseEnter={[Function]}
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/dashboard",
-          "query": Object {
-            "branch": undefined,
-            "id": "foo",
-          },
-        }
-      }
-    >
-      <span
-        className="navbar-search-item-icons little-spacer-right"
-      >
-        <ClockIcon
-          size={12}
-        />
-        <QualifierIcon
-          className="little-spacer-right"
-          qualifier="TRK"
-        />
-      </span>
-      <span
-        className="navbar-search-item-match"
-      >
-        foo
-      </span>
-      <div
-        className="navbar-search-item-right text-muted-2"
-      >
-        bar
-      </div>
-    </Link>
-  </Tooltip>
-</li>
-`;
-
-exports[`renders organizations 2`] = `
-<li
-  key="foo"
->
-  <Tooltip
-    mouseEnterDelay={1}
-    overlay="foo"
-    placement="left"
-    visible={false}
-  >
-    <Link
-      className="navbar-search-item-link"
-      data-key="foo"
-      onClick={[MockFunction]}
-      onMouseEnter={[Function]}
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/dashboard",
-          "query": Object {
-            "branch": undefined,
-            "id": "foo",
-          },
-        }
-      }
-    >
-      <span
-        className="navbar-search-item-icons little-spacer-right"
-      >
-        <ClockIcon
-          size={12}
-        />
-        <QualifierIcon
-          className="little-spacer-right"
-          qualifier="TRK"
-        />
-      </span>
-      <span
-        className="navbar-search-item-match"
-      >
-        foo
-      </span>
-    </Link>
-  </Tooltip>
-</li>
-`;
-
-exports[`renders projects 1`] = `
-<li
-  key="qwe"
->
-  <Tooltip
-    mouseEnterDelay={1}
-    overlay="qwe"
-    placement="left"
-    visible={false}
-  >
-    <Link
-      className="navbar-search-item-link"
-      data-key="qwe"
-      onClick={[MockFunction]}
-      onMouseEnter={[Function]}
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/dashboard",
-          "query": Object {
-            "branch": undefined,
-            "id": "qwe",
-          },
-        }
-      }
-    >
-      <span
-        className="navbar-search-item-icons little-spacer-right"
-      >
-        <ClockIcon
-          size={12}
-        />
-        <QualifierIcon
-          className="little-spacer-right"
-          qualifier="BRC"
-        />
-      </span>
-      <span
-        className="navbar-search-item-match"
-      >
-        qwe
-      </span>
-      <div
-        className="navbar-search-item-right text-muted-2"
-      >
-        foo
-      </div>
-    </Link>
-  </Tooltip>
-</li>
-`;
-
-exports[`renders recently browsed 1`] = `
-<li
-  key="foo"
->
-  <Tooltip
-    mouseEnterDelay={1}
-    overlay="foo"
-    placement="left"
-    visible={false}
-  >
-    <Link
-      className="navbar-search-item-link"
-      data-key="foo"
-      onClick={[MockFunction]}
-      onMouseEnter={[Function]}
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/dashboard",
-          "query": Object {
-            "branch": undefined,
-            "id": "foo",
-          },
-        }
-      }
-    >
-      <span
-        className="navbar-search-item-icons little-spacer-right"
-      >
-        <ClockIcon
-          size={12}
-        />
-        <QualifierIcon
-          className="little-spacer-right"
-          qualifier="TRK"
-        />
-      </span>
-      <span
-        className="navbar-search-item-match"
-      >
-        foo
-      </span>
-    </Link>
-  </Tooltip>
-</li>
-`;
-
-exports[`renders selected 1`] = `
-<li
-  key="foo"
->
-  <Tooltip
-    mouseEnterDelay={1}
-    overlay="foo"
-    placement="left"
-    visible={false}
-  >
-    <Link
-      className="navbar-search-item-link"
-      data-key="foo"
-      onClick={[MockFunction]}
-      onMouseEnter={[Function]}
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/dashboard",
-          "query": Object {
-            "branch": undefined,
-            "id": "foo",
-          },
-        }
-      }
-    >
-      <span
-        className="navbar-search-item-icons little-spacer-right"
-      >
-        <QualifierIcon
-          className="little-spacer-right"
-          qualifier="TRK"
-        />
-      </span>
-      <span
-        className="navbar-search-item-match"
-      >
-        foo
-      </span>
-    </Link>
-  </Tooltip>
-</li>
-`;
-
-exports[`renders selected 2`] = `
-<li
-  className="active"
-  key="foo"
->
-  <Tooltip
-    mouseEnterDelay={1}
-    overlay="foo"
-    placement="left"
-    visible={false}
-  >
-    <Link
-      className="navbar-search-item-link"
-      data-key="foo"
-      onClick={[MockFunction]}
-      onMouseEnter={[Function]}
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/dashboard",
-          "query": Object {
-            "branch": undefined,
-            "id": "foo",
-          },
-        }
-      }
-    >
-      <span
-        className="navbar-search-item-icons little-spacer-right"
-      >
-        <QualifierIcon
-          className="little-spacer-right"
-          qualifier="TRK"
-        />
-      </span>
-      <span
-        className="navbar-search-item-match"
-      >
-        foo
-      </span>
-    </Link>
-  </Tooltip>
-</li>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.tsx.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.tsx.snap
new file mode 100644 (file)
index 0000000..0a9d678
--- /dev/null
@@ -0,0 +1,391 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders favorite 1`] = `
+<li
+  key="foo"
+>
+  <Tooltip
+    mouseEnterDelay={1}
+    overlay="foo"
+    placement="left"
+    visible={false}
+  >
+    <Link
+      className="navbar-search-item-link"
+      data-key="foo"
+      onClick={[MockFunction]}
+      onMouseEnter={[Function]}
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/dashboard",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+          },
+        }
+      }
+    >
+      <span
+        className="navbar-search-item-icons little-spacer-right"
+      >
+        <FavoriteIcon
+          favorite={true}
+          size={12}
+        />
+        <QualifierIcon
+          className="little-spacer-right"
+          qualifier="TRK"
+        />
+      </span>
+      <span
+        className="navbar-search-item-match"
+      >
+        foo
+      </span>
+    </Link>
+  </Tooltip>
+</li>
+`;
+
+exports[`renders match 1`] = `
+<li
+  key="foo"
+>
+  <Tooltip
+    mouseEnterDelay={1}
+    overlay="foo"
+    placement="left"
+    visible={false}
+  >
+    <Link
+      className="navbar-search-item-link"
+      data-key="foo"
+      onClick={[MockFunction]}
+      onMouseEnter={[Function]}
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/dashboard",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+          },
+        }
+      }
+    >
+      <span
+        className="navbar-search-item-icons little-spacer-right"
+      >
+        <QualifierIcon
+          className="little-spacer-right"
+          qualifier="TRK"
+        />
+      </span>
+      <span
+        className="navbar-search-item-match"
+        dangerouslySetInnerHTML={
+          Object {
+            "__html": "f<mark>o</mark>o",
+          }
+        }
+      />
+    </Link>
+  </Tooltip>
+</li>
+`;
+
+exports[`renders organizations 1`] = `
+<li
+  key="foo"
+>
+  <Tooltip
+    mouseEnterDelay={1}
+    overlay="foo"
+    placement="left"
+    visible={false}
+  >
+    <Link
+      className="navbar-search-item-link"
+      data-key="foo"
+      onClick={[MockFunction]}
+      onMouseEnter={[Function]}
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/dashboard",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+          },
+        }
+      }
+    >
+      <span
+        className="navbar-search-item-icons little-spacer-right"
+      >
+        <ClockIcon
+          size={12}
+        />
+        <QualifierIcon
+          className="little-spacer-right"
+          qualifier="TRK"
+        />
+      </span>
+      <span
+        className="navbar-search-item-match"
+      >
+        foo
+      </span>
+      <div
+        className="navbar-search-item-right text-muted-2"
+      >
+        bar
+      </div>
+    </Link>
+  </Tooltip>
+</li>
+`;
+
+exports[`renders organizations 2`] = `
+<li
+  key="foo"
+>
+  <Tooltip
+    mouseEnterDelay={1}
+    overlay="foo"
+    placement="left"
+    visible={false}
+  >
+    <Link
+      className="navbar-search-item-link"
+      data-key="foo"
+      onClick={[MockFunction]}
+      onMouseEnter={[Function]}
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/dashboard",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+          },
+        }
+      }
+    >
+      <span
+        className="navbar-search-item-icons little-spacer-right"
+      >
+        <ClockIcon
+          size={12}
+        />
+        <QualifierIcon
+          className="little-spacer-right"
+          qualifier="TRK"
+        />
+      </span>
+      <span
+        className="navbar-search-item-match"
+      >
+        foo
+      </span>
+    </Link>
+  </Tooltip>
+</li>
+`;
+
+exports[`renders projects 1`] = `
+<li
+  key="qwe"
+>
+  <Tooltip
+    mouseEnterDelay={1}
+    overlay="qwe"
+    placement="left"
+    visible={false}
+  >
+    <Link
+      className="navbar-search-item-link"
+      data-key="qwe"
+      onClick={[MockFunction]}
+      onMouseEnter={[Function]}
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/dashboard",
+          "query": Object {
+            "branch": undefined,
+            "id": "qwe",
+          },
+        }
+      }
+    >
+      <span
+        className="navbar-search-item-icons little-spacer-right"
+      >
+        <ClockIcon
+          size={12}
+        />
+        <QualifierIcon
+          className="little-spacer-right"
+          qualifier="BRC"
+        />
+      </span>
+      <span
+        className="navbar-search-item-match"
+      >
+        qwe
+      </span>
+      <div
+        className="navbar-search-item-right text-muted-2"
+      >
+        foo
+      </div>
+    </Link>
+  </Tooltip>
+</li>
+`;
+
+exports[`renders recently browsed 1`] = `
+<li
+  key="foo"
+>
+  <Tooltip
+    mouseEnterDelay={1}
+    overlay="foo"
+    placement="left"
+    visible={false}
+  >
+    <Link
+      className="navbar-search-item-link"
+      data-key="foo"
+      onClick={[MockFunction]}
+      onMouseEnter={[Function]}
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/dashboard",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+          },
+        }
+      }
+    >
+      <span
+        className="navbar-search-item-icons little-spacer-right"
+      >
+        <ClockIcon
+          size={12}
+        />
+        <QualifierIcon
+          className="little-spacer-right"
+          qualifier="TRK"
+        />
+      </span>
+      <span
+        className="navbar-search-item-match"
+      >
+        foo
+      </span>
+    </Link>
+  </Tooltip>
+</li>
+`;
+
+exports[`renders selected 1`] = `
+<li
+  key="foo"
+>
+  <Tooltip
+    mouseEnterDelay={1}
+    overlay="foo"
+    placement="left"
+    visible={false}
+  >
+    <Link
+      className="navbar-search-item-link"
+      data-key="foo"
+      onClick={[MockFunction]}
+      onMouseEnter={[Function]}
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/dashboard",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+          },
+        }
+      }
+    >
+      <span
+        className="navbar-search-item-icons little-spacer-right"
+      >
+        <QualifierIcon
+          className="little-spacer-right"
+          qualifier="TRK"
+        />
+      </span>
+      <span
+        className="navbar-search-item-match"
+      >
+        foo
+      </span>
+    </Link>
+  </Tooltip>
+</li>
+`;
+
+exports[`renders selected 2`] = `
+<li
+  className="active"
+  key="foo"
+>
+  <Tooltip
+    mouseEnterDelay={1}
+    overlay="foo"
+    placement="left"
+    visible={false}
+  >
+    <Link
+      className="navbar-search-item-link"
+      data-key="foo"
+      onClick={[MockFunction]}
+      onMouseEnter={[Function]}
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/dashboard",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+          },
+        }
+      }
+    >
+      <span
+        className="navbar-search-item-icons little-spacer-right"
+      >
+        <QualifierIcon
+          className="little-spacer-right"
+          qualifier="TRK"
+        />
+      </span>
+      <span
+        className="navbar-search-item-match"
+      >
+        foo
+      </span>
+    </Link>
+  </Tooltip>
+</li>
+`;
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.js.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.js.snap
deleted file mode 100644 (file)
index f13cf14..0000000
+++ /dev/null
@@ -1,111 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders "Show More" link 1`] = `
-<ul
-  className="menu"
->
-  <li
-    className="menu-header"
-    key="header-TRK"
-  >
-    qualifiers.TRK
-  </li>
-  <span
-    key="foo"
-  >
-    foo
-  </span>
-  <span
-    key="bar"
-  >
-    bar
-  </span>
-  <SearchShowMore
-    allowMore={true}
-    key="more-TRK"
-    loadingMore={null}
-    onMoreClick={[MockFunction]}
-    onSelect={[MockFunction]}
-    qualifier="TRK"
-    selected={false}
-  />
-  <li
-    className="divider"
-    key="divider-BRC"
-  />
-  <li
-    className="menu-header"
-    key="header-BRC"
-  >
-    qualifiers.BRC
-  </li>
-  <span
-    key="qwe"
-  >
-    qwe
-  </span>
-  <span
-    key="qux"
-  >
-    qux
-  </span>
-</ul>
-`;
-
-exports[`renders different components and dividers between them 1`] = `
-<ul
-  className="menu"
->
-  <li
-    className="menu-header"
-    key="header-TRK"
-  >
-    qualifiers.TRK
-  </li>
-  <span
-    key="foo"
-  >
-    foo
-  </span>
-  <span
-    key="bar"
-  >
-    bar
-  </span>
-  <li
-    className="divider"
-    key="divider-BRC"
-  />
-  <li
-    className="menu-header"
-    key="header-BRC"
-  >
-    qualifiers.BRC
-  </li>
-  <span
-    key="qwe"
-  >
-    qwe
-  </span>
-  <span
-    key="qux"
-  >
-    qux
-  </span>
-  <li
-    className="divider"
-    key="divider-FIL"
-  />
-  <li
-    className="menu-header"
-    key="header-FIL"
-  >
-    qualifiers.FIL
-  </li>
-  <span
-    key="zux"
-  >
-    zux
-  </span>
-</ul>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.tsx.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.tsx.snap
new file mode 100644 (file)
index 0000000..f49668c
--- /dev/null
@@ -0,0 +1,116 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders "Show More" link 1`] = `
+<ul
+  className="menu"
+>
+  <li
+    className="menu-header"
+    key="header-TRK"
+  >
+    qualifiers.TRK
+  </li>
+  <span
+    key="foo"
+  >
+    foo
+  </span>
+  <span
+    key="bar"
+  >
+    bar
+  </span>
+  <SearchShowMore
+    allowMore={true}
+    key="more-TRK"
+    onMoreClick={[MockFunction]}
+    onSelect={[MockFunction]}
+    qualifier="TRK"
+    selected={false}
+  />
+  <li
+    className="divider"
+    key="divider-BRC"
+  />
+  <li
+    className="menu-header"
+    key="header-BRC"
+  >
+    qualifiers.BRC
+  </li>
+  <span
+    key="qwe"
+  >
+    qwe
+  </span>
+  <span
+    key="qux"
+  >
+    qux
+  </span>
+</ul>
+`;
+
+exports[`renders different components and dividers between them 1`] = `
+<ul
+  className="menu"
+>
+  <li
+    className="menu-header"
+    key="header-TRK"
+  >
+    qualifiers.TRK
+  </li>
+  <span
+    key="foo"
+  >
+    foo
+  </span>
+  <span
+    key="bar"
+  >
+    bar
+  </span>
+  <li
+    className="divider"
+    key="divider-BRC"
+  />
+  <li
+    className="menu-header"
+    key="header-BRC"
+  >
+    qualifiers.BRC
+  </li>
+  <span
+    key="qwe"
+  >
+    qwe
+  </span>
+  <span
+    key="qux"
+  >
+    qux
+  </span>
+  <li
+    className="divider"
+    key="divider-FIL"
+  />
+  <li
+    className="menu-header"
+    key="header-FIL"
+  >
+    qualifiers.FIL
+  </li>
+  <span
+    key="zux"
+  >
+    zux
+  </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 (file)
index 0000000..9a3d97e
--- /dev/null
@@ -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.js
deleted file mode 100644 (file)
index 24e22cd..0000000
+++ /dev/null
@@ -1,48 +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 { sortBy } from 'lodash';
-
-const ORDER = ['DEV', 'VW', 'SVW', 'APP', 'TRK', 'BRC', 'FIL', 'UTS'];
-
-export function sortQualifiers(qualifiers /*: Array<string> */) {
-  return sortBy(qualifiers, qualifier => ORDER.indexOf(qualifier));
-}
-
-/*::
-export type Component = {
-  isFavorite?: boolean,
-  isRecentlyBrowsed?: boolean,
-  key: string,
-  match?: string,
-  name: string,
-  organization?: string,
-  project?: string,
-  qualifier: string
-};
-*/
-
-/*::
-export type Results = { [qualifier: string]: Array<Component> };
-*/
-
-/*::
-export type More = { [string]: number };
-*/
diff --git a/server/sonar-web/src/main/js/app/components/search/utils.ts b/server/sonar-web/src/main/js/app/components/search/utils.ts
new file mode 100644 (file)
index 0000000..1a4d8a9
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * 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 { sortBy } from 'lodash';
+
+const ORDER = ['DEV', 'VW', 'SVW', 'APP', 'TRK', 'BRC', 'FIL', 'UTS'];
+
+export function sortQualifiers(qualifiers: string[]) {
+  return sortBy(qualifiers, qualifier => ORDER.indexOf(qualifier));
+}
+
+export interface ComponentResult {
+  isFavorite?: boolean;
+  isRecentlyBrowsed?: boolean;
+  key: string;
+  match?: string;
+  name: string;
+  organization?: string;
+  project?: string;
+  qualifier: string;
+}
+
+export interface Results {
+  [qualifier: string]: ComponentResult[];
+}
+
+export interface More {
+  [qualifier: string]: number;
+}