Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

Search.tsx 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2022 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import { debounce, keyBy, uniqBy } from 'lodash';
  21. import * as React from 'react';
  22. import { FormattedMessage } from 'react-intl';
  23. import { getSuggestions } from '../../../api/components';
  24. import { DropdownOverlay } from '../../../components/controls/Dropdown';
  25. import OutsideClickHandler from '../../../components/controls/OutsideClickHandler';
  26. import SearchBox from '../../../components/controls/SearchBox';
  27. import { Router, withRouter } from '../../../components/hoc/withRouter';
  28. import ClockIcon from '../../../components/icons/ClockIcon';
  29. import DeferredSpinner from '../../../components/ui/DeferredSpinner';
  30. import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
  31. import { KeyboardKeys } from '../../../helpers/keycodes';
  32. import { translate, translateWithParameters } from '../../../helpers/l10n';
  33. import { scrollToElement } from '../../../helpers/scrolling';
  34. import { getComponentOverviewUrl } from '../../../helpers/urls';
  35. import { ComponentQualifier } from '../../../types/component';
  36. import { Dict } from '../../../types/types';
  37. import RecentHistory from '../RecentHistory';
  38. import './Search.css';
  39. import SearchResult from './SearchResult';
  40. import SearchResults from './SearchResults';
  41. import { ComponentResult, More, Results, sortQualifiers } from './utils';
  42. interface Props {
  43. router: Router;
  44. }
  45. interface State {
  46. loading: boolean;
  47. loadingMore?: string;
  48. more: More;
  49. open: boolean;
  50. projects: Dict<{ name: string }>;
  51. query: string;
  52. results: Results;
  53. selected?: string;
  54. shortQuery: boolean;
  55. }
  56. export class Search extends React.PureComponent<Props, State> {
  57. input?: HTMLInputElement | null;
  58. node?: HTMLElement | null;
  59. nodes: Dict<HTMLElement>;
  60. mounted = false;
  61. constructor(props: Props) {
  62. super(props);
  63. this.nodes = {};
  64. this.search = debounce(this.search, 250);
  65. this.state = {
  66. loading: false,
  67. more: {},
  68. open: false,
  69. projects: {},
  70. query: '',
  71. results: {},
  72. shortQuery: false
  73. };
  74. }
  75. componentDidMount() {
  76. this.mounted = true;
  77. document.addEventListener('keydown', this.handleSKeyDown);
  78. }
  79. componentDidUpdate(_prevProps: Props, prevState: State) {
  80. if (prevState.selected !== this.state.selected) {
  81. this.scrollToSelected();
  82. }
  83. }
  84. componentWillUnmount() {
  85. this.mounted = false;
  86. document.removeEventListener('keydown', this.handleSKeyDown);
  87. }
  88. focusInput = () => {
  89. if (this.input) {
  90. this.input.focus();
  91. }
  92. };
  93. handleClickOutside = () => {
  94. this.closeSearch(false);
  95. };
  96. handleFocus = () => {
  97. if (!this.state.open) {
  98. // simulate click to close any other dropdowns
  99. const body = document.documentElement;
  100. if (body) {
  101. body.click();
  102. }
  103. }
  104. this.openSearch();
  105. };
  106. openSearch = () => {
  107. if (!this.state.open && !this.state.query) {
  108. this.search('');
  109. }
  110. this.setState({ open: true });
  111. };
  112. closeSearch = (clear = true) => {
  113. if (this.input) {
  114. this.input.blur();
  115. }
  116. if (clear) {
  117. this.setState({
  118. more: {},
  119. open: false,
  120. projects: {},
  121. query: '',
  122. results: {},
  123. selected: undefined,
  124. shortQuery: false
  125. });
  126. } else {
  127. this.setState({ open: false });
  128. }
  129. };
  130. getPlainComponentsList = (results: Results, more: More) =>
  131. sortQualifiers(Object.keys(results)).reduce((components, qualifier) => {
  132. const next = [...components, ...results[qualifier].map(component => component.key)];
  133. if (more[qualifier]) {
  134. next.push('qualifier###' + qualifier);
  135. }
  136. return next;
  137. }, []);
  138. stopLoading = () => {
  139. if (this.mounted) {
  140. this.setState({ loading: false });
  141. }
  142. };
  143. search = (query: string) => {
  144. if (query.length === 0 || query.length >= 2) {
  145. this.setState({ loading: true });
  146. const recentlyBrowsed = RecentHistory.get().map(component => component.key);
  147. getSuggestions(query, recentlyBrowsed).then(response => {
  148. // compare `this.state.query` and `query` to handle two request done almost at the same time
  149. // in this case only the request that matches the current query should be taken
  150. if (this.mounted && this.state.query === query) {
  151. const results: Results = {};
  152. const more: More = {};
  153. this.nodes = {};
  154. response.results.forEach(group => {
  155. results[group.q] = group.items.map(item => ({ ...item, qualifier: group.q }));
  156. more[group.q] = group.more;
  157. });
  158. const list = this.getPlainComponentsList(results, more);
  159. this.setState(state => ({
  160. loading: false,
  161. more,
  162. projects: { ...state.projects, ...keyBy(response.projects, 'key') },
  163. results,
  164. selected: list.length > 0 ? list[0] : undefined,
  165. shortQuery: query.length > 2 && response.warning === 'short_input'
  166. }));
  167. }
  168. }, this.stopLoading);
  169. } else {
  170. this.setState({ loading: false });
  171. }
  172. };
  173. searchMore = (qualifier: string) => {
  174. const { query } = this.state;
  175. if (query.length === 1) {
  176. return;
  177. }
  178. this.setState({ loading: true, loadingMore: qualifier });
  179. const recentlyBrowsed = RecentHistory.get().map(component => component.key);
  180. getSuggestions(query, recentlyBrowsed, qualifier).then(response => {
  181. if (this.mounted) {
  182. const group = response.results.find(group => group.q === qualifier);
  183. const moreResults = (group ? group.items : []).map(item => ({ ...item, qualifier }));
  184. this.setState(state => ({
  185. loading: false,
  186. loadingMore: undefined,
  187. more: { ...state.more, [qualifier]: 0 },
  188. projects: { ...state.projects, ...keyBy(response.projects, 'key') },
  189. results: {
  190. ...state.results,
  191. [qualifier]: uniqBy([...state.results[qualifier], ...moreResults], 'key')
  192. },
  193. selected: moreResults.length > 0 ? moreResults[0].key : state.selected
  194. }));
  195. this.focusInput();
  196. }
  197. }, this.stopLoading);
  198. };
  199. handleQueryChange = (query: string) => {
  200. this.setState({ query, shortQuery: query.length === 1 });
  201. this.search(query);
  202. };
  203. selectPrevious = () => {
  204. this.setState(({ more, results, selected }) => {
  205. if (selected) {
  206. const list = this.getPlainComponentsList(results, more);
  207. const index = list.indexOf(selected);
  208. return index > 0 ? { selected: list[index - 1] } : null;
  209. }
  210. return null;
  211. });
  212. };
  213. selectNext = () => {
  214. this.setState(({ more, results, selected }) => {
  215. if (selected) {
  216. const list = this.getPlainComponentsList(results, more);
  217. const index = list.indexOf(selected);
  218. return index >= 0 && index < list.length - 1 ? { selected: list[index + 1] } : null;
  219. }
  220. return null;
  221. });
  222. };
  223. openSelected = () => {
  224. const { results, selected } = this.state;
  225. if (!selected) {
  226. return;
  227. }
  228. if (selected.startsWith('qualifier###')) {
  229. this.searchMore(selected.substr(12));
  230. } else {
  231. let qualifier = ComponentQualifier.Project;
  232. if ((results[ComponentQualifier.Portfolio] ?? []).find(r => r.key === selected)) {
  233. qualifier = ComponentQualifier.Portfolio;
  234. } else if ((results[ComponentQualifier.SubPortfolio] ?? []).find(r => r.key === selected)) {
  235. qualifier = ComponentQualifier.SubPortfolio;
  236. }
  237. this.props.router.push(getComponentOverviewUrl(selected, qualifier));
  238. this.closeSearch();
  239. }
  240. };
  241. scrollToSelected = () => {
  242. if (this.state.selected) {
  243. const node = this.nodes[this.state.selected];
  244. if (node && this.node) {
  245. scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node });
  246. }
  247. }
  248. };
  249. handleSKeyDown = (event: KeyboardEvent) => {
  250. if (isInput(event) || isShortcut(event)) {
  251. return true;
  252. }
  253. if (event.key === KeyboardKeys.KeyS) {
  254. event.preventDefault();
  255. this.focusInput();
  256. this.openSearch();
  257. }
  258. };
  259. handleKeyDown = (event: React.KeyboardEvent) => {
  260. switch (event.nativeEvent.key) {
  261. case KeyboardKeys.Enter:
  262. event.preventDefault();
  263. event.nativeEvent.stopImmediatePropagation();
  264. this.openSelected();
  265. break;
  266. case KeyboardKeys.UpArrow:
  267. event.preventDefault();
  268. event.nativeEvent.stopImmediatePropagation();
  269. this.selectPrevious();
  270. break;
  271. case KeyboardKeys.Escape:
  272. event.preventDefault();
  273. event.nativeEvent.stopImmediatePropagation();
  274. this.closeSearch();
  275. break;
  276. case KeyboardKeys.DownArrow:
  277. event.preventDefault();
  278. event.nativeEvent.stopImmediatePropagation();
  279. this.selectNext();
  280. break;
  281. }
  282. };
  283. handleSelect = (selected: string) => {
  284. this.setState({ selected });
  285. };
  286. innerRef = (component: string, node: HTMLElement | null) => {
  287. if (node) {
  288. this.nodes[component] = node;
  289. }
  290. };
  291. searchInputRef = (node: HTMLInputElement | null) => {
  292. this.input = node;
  293. };
  294. renderResult = (component: ComponentResult) => (
  295. <SearchResult
  296. component={component}
  297. innerRef={this.innerRef}
  298. key={component.key}
  299. onClose={this.closeSearch}
  300. onSelect={this.handleSelect}
  301. projects={this.state.projects}
  302. selected={this.state.selected === component.key}
  303. />
  304. );
  305. renderNoResults = () => (
  306. <div className="navbar-search-no-results">
  307. {translateWithParameters('no_results_for_x', this.state.query)}
  308. </div>
  309. );
  310. render() {
  311. const search = (
  312. <li className="navbar-search dropdown">
  313. <DeferredSpinner className="navbar-search-icon" loading={this.state.loading} />
  314. <SearchBox
  315. autoFocus={this.state.open}
  316. innerRef={this.searchInputRef}
  317. minLength={2}
  318. onChange={this.handleQueryChange}
  319. onFocus={this.handleFocus}
  320. onKeyDown={this.handleKeyDown}
  321. placeholder={translate('search.placeholder')}
  322. value={this.state.query}
  323. />
  324. {this.state.shortQuery && (
  325. <span className="navbar-search-input-hint">
  326. {translateWithParameters('select2.tooShort', 2)}
  327. </span>
  328. )}
  329. {this.state.open && Object.keys(this.state.results).length > 0 && (
  330. <DropdownOverlay noPadding={true}>
  331. <div className="global-navbar-search-dropdown" ref={node => (this.node = node)}>
  332. <SearchResults
  333. allowMore={this.state.query.length !== 1}
  334. loadingMore={this.state.loadingMore}
  335. more={this.state.more}
  336. onMoreClick={this.searchMore}
  337. onSelect={this.handleSelect}
  338. renderNoResults={this.renderNoResults}
  339. renderResult={this.renderResult}
  340. results={this.state.results}
  341. selected={this.state.selected}
  342. />
  343. <div className="dropdown-bottom-hint">
  344. <div className="pull-right">
  345. <ClockIcon className="little-spacer-right" size={12} />
  346. {translate('recently_browsed')}
  347. </div>
  348. <FormattedMessage
  349. defaultMessage={translate('search.shortcut_hint')}
  350. id="search.shortcut_hint"
  351. values={{
  352. shortcut: <span className="shortcut-button shortcut-button-small">s</span>
  353. }}
  354. />
  355. </div>
  356. </div>
  357. </DropdownOverlay>
  358. )}
  359. </li>
  360. );
  361. return this.state.open ? (
  362. <OutsideClickHandler onClickOutside={this.handleClickOutside}>{search}</OutsideClickHandler>
  363. ) : (
  364. search
  365. );
  366. }
  367. }
  368. export default withRouter(Search);