index.tsx 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393
  1. import React from 'react';
  2. import {browserHistory, withRouter, WithRouterProps} from 'react-router';
  3. import {ClassNames, withTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import * as Sentry from '@sentry/react';
  6. import createReactClass from 'create-react-class';
  7. import debounce from 'lodash/debounce';
  8. import Reflux from 'reflux';
  9. import {addErrorMessage} from 'app/actionCreators/indicator';
  10. import {
  11. fetchRecentSearches,
  12. pinSearch,
  13. saveRecentSearch,
  14. unpinSearch,
  15. } from 'app/actionCreators/savedSearches';
  16. import {Client} from 'app/api';
  17. import Button from 'app/components/button';
  18. import ButtonBar from 'app/components/buttonBar';
  19. import DropdownLink from 'app/components/dropdownLink';
  20. import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
  21. import {
  22. DEFAULT_DEBOUNCE_DURATION,
  23. MAX_AUTOCOMPLETE_RELEASES,
  24. NEGATION_OPERATOR,
  25. } from 'app/constants';
  26. import {IconClose, IconEllipsis, IconPin, IconSearch, IconSliders} from 'app/icons';
  27. import {t} from 'app/locale';
  28. import MemberListStore from 'app/stores/memberListStore';
  29. import space from 'app/styles/space';
  30. import {LightWeightOrganization, SavedSearch, SavedSearchType, Tag} from 'app/types';
  31. import {defined} from 'app/utils';
  32. import {trackAnalyticsEvent} from 'app/utils/analytics';
  33. import {callIfFunction} from 'app/utils/callIfFunction';
  34. import commonTheme, {Theme} from 'app/utils/theme';
  35. import withApi from 'app/utils/withApi';
  36. import withOrganization from 'app/utils/withOrganization';
  37. import CreateSavedSearchButton from 'app/views/issueList/createSavedSearchButton';
  38. import SearchDropdown from './searchDropdown';
  39. import {ItemType, SearchGroup, SearchItem} from './types';
  40. import {
  41. addSpace,
  42. createSearchGroups,
  43. filterSearchGroupsByIndex,
  44. removeSpace,
  45. } from './utils';
  46. const DROPDOWN_BLUR_DURATION = 200;
  47. const getMediaQuery = (size: string, type: React.CSSProperties['display']) => `
  48. display: ${type};
  49. @media (min-width: ${size}) {
  50. display: ${type === 'none' ? 'block' : 'none'};
  51. }
  52. `;
  53. const getInputButtonStyles = (p: {
  54. isActive?: boolean;
  55. collapseIntoEllipsisMenu?: number;
  56. }) => `
  57. color: ${p.isActive ? commonTheme.blue300 : commonTheme.gray300};
  58. width: 18px;
  59. &,
  60. &:hover,
  61. &:focus {
  62. background: transparent;
  63. }
  64. &:hover {
  65. color: ${commonTheme.gray400};
  66. }
  67. ${
  68. p.collapseIntoEllipsisMenu &&
  69. getMediaQuery(commonTheme.breakpoints[p.collapseIntoEllipsisMenu], 'none')
  70. };
  71. `;
  72. type DropdownElementStylesProps = {
  73. theme: Theme;
  74. showBelowMediaQuery: number;
  75. last?: boolean;
  76. };
  77. const getDropdownElementStyles = (p: DropdownElementStylesProps) => `
  78. padding: 0 ${space(1)} ${p.last ? null : space(0.5)};
  79. margin-bottom: ${p.last ? null : space(0.5)};
  80. display: none;
  81. color: ${p.theme.textColor};
  82. align-items: center;
  83. min-width: 190px;
  84. height: 38px;
  85. padding-left: ${space(1.5)};
  86. padding-right: ${space(1.5)};
  87. &,
  88. &:hover,
  89. &:focus {
  90. border-bottom: ${p.last ? null : `1px solid ${p.theme.border}`};
  91. border-radius: 0;
  92. }
  93. &:hover {
  94. color: ${p.theme.blue300};
  95. }
  96. & > svg {
  97. margin-right: ${space(1)};
  98. }
  99. ${
  100. p.showBelowMediaQuery &&
  101. getMediaQuery(commonTheme.breakpoints[p.showBelowMediaQuery], 'flex')
  102. }
  103. `;
  104. const ThemedCreateSavedSearchButton = withTheme(
  105. (props: {theme: Theme; query; sort; organization}) => (
  106. <ClassNames>
  107. {({css}) => (
  108. <CreateSavedSearchButton
  109. buttonClassName={css`
  110. ${getDropdownElementStyles({
  111. theme: props.theme,
  112. showBelowMediaQuery: 2,
  113. last: false,
  114. })}
  115. `}
  116. tooltipClassName={css`
  117. ${getMediaQuery(commonTheme.breakpoints[2], 'none')}
  118. `}
  119. {...props}
  120. />
  121. )}
  122. </ClassNames>
  123. )
  124. );
  125. type Props = WithRouterProps & {
  126. api: Client;
  127. organization: LightWeightOrganization;
  128. dropdownClassName?: string;
  129. className?: string;
  130. defaultQuery?: string;
  131. query?: string | null;
  132. sort?: string;
  133. /**
  134. * Prepare query value before filtering dropdown items
  135. */
  136. prepareQuery?: (query: string) => string;
  137. /**
  138. * Search items to display when there's no tag key. Is a tuple of search items and recent search items
  139. */
  140. defaultSearchItems?: [SearchItem[], SearchItem[]];
  141. /**
  142. * Disabled control (e.g. read-only)
  143. */
  144. disabled?: boolean;
  145. /**
  146. * Input placeholder
  147. */
  148. placeholder?: string;
  149. /**
  150. * Allows additional content to be played before the search bar and icon
  151. */
  152. inlineLabel?: React.ReactNode;
  153. /**
  154. * Map of tags
  155. */
  156. supportedTags?: {[key: string]: Tag};
  157. /**
  158. * Maximum number of search items to display or a falsey value for no
  159. * maximum
  160. */
  161. maxSearchItems?: number;
  162. /**
  163. * List user's recent searches
  164. */
  165. hasRecentSearches?: boolean;
  166. /**
  167. * Has search builder UI
  168. */
  169. hasSearchBuilder?: boolean;
  170. /**
  171. * Can create a saved search
  172. */
  173. canCreateSavedSearch?: boolean;
  174. /**
  175. * Wrap the input with a form. Useful if search bar is used within a parent
  176. * form
  177. */
  178. useFormWrapper?: boolean;
  179. /**
  180. * If this is defined, attempt to save search term scoped to the user and
  181. * the current org
  182. */
  183. savedSearchType?: SavedSearchType;
  184. /**
  185. * Has pinned search feature
  186. */
  187. hasPinnedSearch?: boolean;
  188. /**
  189. * The pinned search object
  190. */
  191. pinnedSearch?: SavedSearch;
  192. /**
  193. * Get a list of tag values for the passed tag
  194. */
  195. onGetTagValues?: (tag: Tag, query: string, params: object) => Promise<string[]>;
  196. /**
  197. * Get a list of recent searches for the current query
  198. */
  199. onGetRecentSearches?: (query: string) => Promise<SearchItem[]>;
  200. /**
  201. * Called when the user makes a search
  202. */
  203. onSearch?: (query: string) => void;
  204. /**
  205. * Called when the search input changes
  206. */
  207. onChange?: (value: string, e: React.ChangeEvent) => void;
  208. /**
  209. * Called when the search is blurred
  210. */
  211. onBlur?: (value: string) => void;
  212. /**
  213. * Called on key down
  214. */
  215. onKeyDown?: (evt: React.KeyboardEvent<HTMLInputElement>) => void;
  216. /**
  217. * Called when a recent search is saved
  218. */
  219. onSavedRecentSearch?: (query: string) => void;
  220. /**
  221. * Called when the sidebar is toggled
  222. */
  223. onSidebarToggle?: React.EventHandler<React.MouseEvent>;
  224. /**
  225. * If true, excludes the environment tag from the autocompletion list. This
  226. * is because we don't want to treat environment as a tag in some places
  227. * such as the stream view where it is a top level concept
  228. */
  229. excludeEnvironment?: boolean;
  230. /**
  231. * Used to enforce length on the query
  232. */
  233. maxQueryLength?: number;
  234. };
  235. type State = {
  236. /**
  237. * The current search query in the input
  238. */
  239. query: string;
  240. sort?: string;
  241. /**
  242. * The query in the input since we last updated our autocomplete list.
  243. */
  244. previousQuery?: string;
  245. /**
  246. * Indicates that we have a query that we've already determined not to have
  247. * any values. This is used to stop the autocompleter from querying if we
  248. * know we will find nothing.
  249. */
  250. noValueQuery?: string;
  251. /**
  252. * The current search term (or 'key') that that we will be showing
  253. * autocompletion for.
  254. */
  255. searchTerm: string;
  256. searchGroups: SearchGroup[];
  257. flatSearchItems: SearchItem[];
  258. /**
  259. * Index of the focused search item
  260. */
  261. activeSearchItem: number;
  262. tags: {[key: string]: string};
  263. dropdownVisible: boolean;
  264. loading: boolean;
  265. };
  266. class SmartSearchBar extends React.Component<Props, State> {
  267. /**
  268. * Given a query, and the current cursor position, return the string-delimiting
  269. * index of the search term designated by the cursor.
  270. */
  271. static getLastTermIndex = (query: string, cursor: number) => {
  272. // TODO: work with quoted-terms
  273. const cursorOffset = query.slice(cursor).search(/\s|$/);
  274. return cursor + (cursorOffset === -1 ? 0 : cursorOffset);
  275. };
  276. /**
  277. * Returns an array of query terms, including incomplete terms
  278. *
  279. * e.g. ["is:unassigned", "browser:\"Chrome 33.0\"", "assigned"]
  280. */
  281. static getQueryTerms = (query: string, cursor: number) =>
  282. query.slice(0, cursor).match(/\S+:"[^"]*"?|\S+/g);
  283. static defaultProps = {
  284. defaultQuery: '',
  285. query: null,
  286. onSearch: function () {},
  287. excludeEnvironment: false,
  288. placeholder: t('Search for events, users, tags, and everything else.'),
  289. supportedTags: {},
  290. defaultSearchItems: [[], []],
  291. hasPinnedSearch: false,
  292. useFormWrapper: true,
  293. savedSearchType: SavedSearchType.ISSUE,
  294. };
  295. state: State = {
  296. query:
  297. this.props.query !== null
  298. ? addSpace(this.props.query)
  299. : this.props.defaultQuery ?? '',
  300. searchTerm: '',
  301. searchGroups: [],
  302. flatSearchItems: [],
  303. activeSearchItem: -1,
  304. tags: {},
  305. dropdownVisible: false,
  306. loading: false,
  307. };
  308. UNSAFE_componentWillReceiveProps(nextProps) {
  309. // query was updated by another source (e.g. sidebar filters)
  310. if (nextProps.query !== this.props.query) {
  311. this.setState({
  312. query: addSpace(nextProps.query),
  313. });
  314. }
  315. }
  316. componentWillUnmount() {
  317. if (this.blurTimeout) {
  318. clearTimeout(this.blurTimeout);
  319. this.blurTimeout = undefined;
  320. }
  321. }
  322. /**
  323. * Tracks the dropdown blur
  324. */
  325. blurTimeout?: number;
  326. /**
  327. * Ref to the search element itself
  328. */
  329. searchInput = React.createRef<HTMLInputElement>();
  330. blur = () => {
  331. if (!this.searchInput.current) {
  332. return;
  333. }
  334. this.searchInput.current.blur();
  335. };
  336. onSubmit = (evt: React.FormEvent) => {
  337. const {organization, savedSearchType} = this.props;
  338. evt.preventDefault();
  339. trackAnalyticsEvent({
  340. eventKey: 'search.searched',
  341. eventName: 'Search: Performed search',
  342. organization_id: organization.id,
  343. query: removeSpace(this.state.query),
  344. search_type: savedSearchType === 0 ? 'issues' : 'events',
  345. search_source: 'main_search',
  346. });
  347. this.doSearch();
  348. };
  349. doSearch = async () => {
  350. const {
  351. onSearch,
  352. onSavedRecentSearch,
  353. api,
  354. organization,
  355. savedSearchType,
  356. } = this.props;
  357. this.blur();
  358. const query = removeSpace(this.state.query);
  359. callIfFunction(onSearch, query);
  360. // Only save recent search query if we have a savedSearchType (also 0 is a valid value)
  361. // Do not save empty string queries (i.e. if they clear search)
  362. if (typeof savedSearchType === 'undefined' || !query) {
  363. return;
  364. }
  365. try {
  366. await saveRecentSearch(api, organization.slug, savedSearchType, query);
  367. if (onSavedRecentSearch) {
  368. onSavedRecentSearch(query);
  369. }
  370. } catch (err) {
  371. // Silently capture errors if it fails to save
  372. Sentry.captureException(err);
  373. }
  374. };
  375. clearSearch = () =>
  376. this.setState({query: ''}, () =>
  377. callIfFunction(this.props.onSearch, this.state.query)
  378. );
  379. onQueryFocus = () => this.setState({dropdownVisible: true});
  380. onQueryBlur = (e: React.FocusEvent<HTMLInputElement>) => {
  381. // wait before closing dropdown in case blur was a result of clicking a
  382. // menu option
  383. const value = e.target.value;
  384. const blurHandler = () => {
  385. this.blurTimeout = undefined;
  386. this.setState({dropdownVisible: false});
  387. callIfFunction(this.props.onBlur, value);
  388. };
  389. this.blurTimeout = window.setTimeout(blurHandler, DROPDOWN_BLUR_DURATION);
  390. };
  391. onQueryChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
  392. this.setState({query: evt.target.value}, this.updateAutoCompleteItems);
  393. callIfFunction(this.props.onChange, evt.target.value, evt);
  394. };
  395. onInputClick = () => this.updateAutoCompleteItems();
  396. /**
  397. * Handle keyboard navigation
  398. */
  399. onKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
  400. const {onKeyDown} = this.props;
  401. const {key} = evt;
  402. callIfFunction(onKeyDown, evt);
  403. if (!this.state.searchGroups.length) {
  404. return;
  405. }
  406. const {useFormWrapper} = this.props;
  407. const isSelectingDropdownItems = this.state.activeSearchItem !== -1;
  408. if (key === 'ArrowDown' || key === 'ArrowUp') {
  409. evt.preventDefault();
  410. const {flatSearchItems, activeSearchItem} = this.state;
  411. const searchGroups = [...this.state.searchGroups];
  412. const [groupIndex, childrenIndex] = isSelectingDropdownItems
  413. ? filterSearchGroupsByIndex(searchGroups, activeSearchItem)
  414. : [];
  415. // Remove the previous 'active' property
  416. if (typeof groupIndex !== 'undefined') {
  417. if (
  418. childrenIndex !== undefined &&
  419. searchGroups[groupIndex]?.children?.[childrenIndex]
  420. ) {
  421. delete searchGroups[groupIndex].children[childrenIndex].active;
  422. }
  423. }
  424. const currIndex = isSelectingDropdownItems ? activeSearchItem : 0;
  425. const totalItems = flatSearchItems.length;
  426. // Move the selected index up/down
  427. const nextActiveSearchItem =
  428. key === 'ArrowUp'
  429. ? (currIndex - 1 + totalItems) % totalItems
  430. : isSelectingDropdownItems
  431. ? (currIndex + 1) % totalItems
  432. : 0;
  433. const [nextGroupIndex, nextChildrenIndex] = filterSearchGroupsByIndex(
  434. searchGroups,
  435. nextActiveSearchItem
  436. );
  437. // Make sure search items exist (e.g. both groups could be empty) and
  438. // attach the 'active' property to the item
  439. if (
  440. nextGroupIndex !== undefined &&
  441. nextChildrenIndex !== undefined &&
  442. searchGroups[nextGroupIndex]?.children
  443. ) {
  444. searchGroups[nextGroupIndex].children[nextChildrenIndex] = {
  445. ...searchGroups[nextGroupIndex].children[nextChildrenIndex],
  446. active: true,
  447. };
  448. }
  449. this.setState({searchGroups, activeSearchItem: nextActiveSearchItem});
  450. }
  451. if ((key === 'Tab' || key === 'Enter') && isSelectingDropdownItems) {
  452. evt.preventDefault();
  453. const {activeSearchItem, searchGroups} = this.state;
  454. const [groupIndex, childrenIndex] = filterSearchGroupsByIndex(
  455. searchGroups,
  456. activeSearchItem
  457. );
  458. const item =
  459. groupIndex !== undefined &&
  460. childrenIndex !== undefined &&
  461. searchGroups[groupIndex].children[childrenIndex];
  462. if (item && !this.isDefaultDropdownItem(item)) {
  463. this.onAutoComplete(item.value, item);
  464. }
  465. return;
  466. }
  467. if (key === 'Enter') {
  468. if (!useFormWrapper && !isSelectingDropdownItems) {
  469. // If enter is pressed, and we are not wrapping input in a `<form>`,
  470. // and we are not selecting an item from the dropdown, then we should
  471. // consider the user as finished selecting and perform a "search" since
  472. // there is no `<form>` to catch and call `this.onSubmit`
  473. this.doSearch();
  474. }
  475. return;
  476. }
  477. };
  478. onKeyUp = (evt: React.KeyboardEvent<HTMLInputElement>) => {
  479. // Other keys are managed at onKeyDown function
  480. if (evt.key !== 'Escape') {
  481. return;
  482. }
  483. evt.preventDefault();
  484. const isSelectingDropdownItems = this.state.activeSearchItem > -1;
  485. if (!isSelectingDropdownItems) {
  486. this.blur();
  487. return;
  488. }
  489. const {searchGroups, activeSearchItem} = this.state;
  490. const [groupIndex, childrenIndex] = isSelectingDropdownItems
  491. ? filterSearchGroupsByIndex(searchGroups, activeSearchItem)
  492. : [];
  493. if (groupIndex !== undefined && childrenIndex !== undefined) {
  494. delete searchGroups[groupIndex].children[childrenIndex].active;
  495. }
  496. this.setState({
  497. activeSearchItem: -1,
  498. searchGroups: [...this.state.searchGroups],
  499. });
  500. };
  501. getCursorPosition = () => {
  502. if (!this.searchInput.current) {
  503. return -1;
  504. }
  505. return this.searchInput.current.selectionStart ?? -1;
  506. };
  507. /**
  508. * Returns array of possible key values that substring match `query`
  509. */
  510. getTagKeys = (query: string): SearchItem[] => {
  511. const {prepareQuery} = this.props;
  512. const supportedTags = this.props.supportedTags ?? {};
  513. // Return all if query is empty
  514. let tagKeys = Object.keys(supportedTags).map(key => `${key}:`);
  515. if (query) {
  516. const preparedQuery =
  517. typeof prepareQuery === 'function' ? prepareQuery(query) : query;
  518. tagKeys = tagKeys.filter(key => key.indexOf(preparedQuery) > -1);
  519. }
  520. // If the environment feature is active and excludeEnvironment = true
  521. // then remove the environment key
  522. if (this.props.excludeEnvironment) {
  523. tagKeys = tagKeys.filter(key => key !== 'environment:');
  524. }
  525. return tagKeys.map(value => ({value, desc: value}));
  526. };
  527. /**
  528. * Returns array of tag values that substring match `query`; invokes `callback`
  529. * with data when ready
  530. */
  531. getTagValues = debounce(
  532. async (tag: Tag, query: string) => {
  533. // Strip double quotes if there are any
  534. query = query.replace(/"/g, '').trim();
  535. if (!this.props.onGetTagValues) {
  536. return [];
  537. }
  538. if (
  539. this.state.noValueQuery !== undefined &&
  540. query.startsWith(this.state.noValueQuery)
  541. ) {
  542. return [];
  543. }
  544. const {location} = this.props;
  545. const endpointParams = getParams(location.query);
  546. this.setState({loading: true});
  547. let values: string[] = [];
  548. try {
  549. values = await this.props.onGetTagValues(tag, query, endpointParams);
  550. this.setState({loading: false});
  551. } catch (err) {
  552. this.setState({loading: false});
  553. Sentry.captureException(err);
  554. return [];
  555. }
  556. if (tag.key === 'release:' && !values.includes('latest')) {
  557. values.unshift('latest');
  558. }
  559. const noValueQuery = values.length === 0 && query.length > 0 ? query : undefined;
  560. this.setState({noValueQuery});
  561. return values.map(value => {
  562. // Wrap in quotes if there is a space
  563. const escapedValue =
  564. value.includes(' ') || value.includes('"')
  565. ? `"${value.replace(/"/g, '\\"')}"`
  566. : value;
  567. return {value: escapedValue, desc: escapedValue, type: 'tag-value' as ItemType};
  568. });
  569. },
  570. DEFAULT_DEBOUNCE_DURATION,
  571. {leading: true}
  572. );
  573. /**
  574. * Returns array of tag values that substring match `query`; invokes `callback`
  575. * with results
  576. */
  577. getPredefinedTagValues = (tag: Tag, query: string): SearchItem[] =>
  578. (tag.values ?? [])
  579. .filter(value => value.indexOf(query) > -1)
  580. .map((value, i) => ({
  581. value,
  582. desc: value,
  583. type: 'tag-value',
  584. ignoreMaxSearchItems: tag.maxSuggestedValues ? i < tag.maxSuggestedValues : false,
  585. }));
  586. /**
  587. * Get recent searches
  588. */
  589. getRecentSearches = debounce(
  590. async () => {
  591. const {savedSearchType, hasRecentSearches, onGetRecentSearches} = this.props;
  592. // `savedSearchType` can be 0
  593. if (!defined(savedSearchType) || !hasRecentSearches) {
  594. return [];
  595. }
  596. const fetchFn = onGetRecentSearches || this.fetchRecentSearches;
  597. return await fetchFn(this.state.query);
  598. },
  599. DEFAULT_DEBOUNCE_DURATION,
  600. {leading: true}
  601. );
  602. fetchRecentSearches = async (fullQuery: string): Promise<SearchItem[]> => {
  603. const {api, organization, savedSearchType} = this.props;
  604. if (savedSearchType === undefined) {
  605. return [];
  606. }
  607. try {
  608. const recentSearches: any[] = await fetchRecentSearches(
  609. api,
  610. organization.slug,
  611. savedSearchType,
  612. fullQuery
  613. );
  614. // If `recentSearches` is undefined or not an array, the function will
  615. // return an array anyway
  616. return recentSearches.map(searches => ({
  617. desc: searches.query,
  618. value: searches.query,
  619. type: 'recent-search',
  620. }));
  621. } catch (e) {
  622. Sentry.captureException(e);
  623. }
  624. return [];
  625. };
  626. getReleases = debounce(
  627. async (tag: Tag, query: string) => {
  628. const releasePromise = this.fetchReleases(query);
  629. const tags = this.getPredefinedTagValues(tag, query);
  630. const tagValues = tags.map<SearchItem>(v => ({
  631. ...v,
  632. type: 'first-release',
  633. }));
  634. const releases = await releasePromise;
  635. const releaseValues = releases.map<SearchItem>((r: any) => ({
  636. value: r.shortVersion,
  637. desc: r.shortVersion,
  638. type: 'first-release',
  639. }));
  640. return [...tagValues, ...releaseValues];
  641. },
  642. DEFAULT_DEBOUNCE_DURATION,
  643. {leading: true}
  644. );
  645. /**
  646. * Fetches latest releases from a organization/project. Returns an empty array
  647. * if an error is encountered.
  648. */
  649. fetchReleases = async (releaseVersion: string): Promise<any[]> => {
  650. const {api, location, organization} = this.props;
  651. const project = location && location.query ? location.query.projectId : undefined;
  652. const url = `/organizations/${organization.slug}/releases/`;
  653. const fetchQuery: {[key: string]: string | number} = {
  654. per_page: MAX_AUTOCOMPLETE_RELEASES,
  655. };
  656. if (releaseVersion) {
  657. fetchQuery.query = releaseVersion;
  658. }
  659. if (project) {
  660. fetchQuery.project = project;
  661. }
  662. try {
  663. return await api.requestPromise(url, {
  664. method: 'GET',
  665. query: fetchQuery,
  666. });
  667. } catch (e) {
  668. addErrorMessage(t('Unable to fetch releases'));
  669. Sentry.captureException(e);
  670. }
  671. return [];
  672. };
  673. updateAutoCompleteItems = async () => {
  674. if (this.blurTimeout) {
  675. clearTimeout(this.blurTimeout);
  676. this.blurTimeout = undefined;
  677. }
  678. const cursor = this.getCursorPosition();
  679. let query = this.state.query;
  680. // Don't continue if the query hasn't changed
  681. if (query === this.state.previousQuery) {
  682. return;
  683. }
  684. this.setState({previousQuery: query});
  685. const lastTermIndex = SmartSearchBar.getLastTermIndex(query, cursor);
  686. const terms = SmartSearchBar.getQueryTerms(query, lastTermIndex);
  687. if (
  688. !terms || // no terms
  689. terms.length === 0 || // no terms
  690. (terms.length === 1 && terms[0] === this.props.defaultQuery) || // default term
  691. /^\s+$/.test(query.slice(cursor - 1, cursor + 1))
  692. ) {
  693. const [defaultSearchItems, defaultRecentItems] = this.props.defaultSearchItems!;
  694. if (!defaultSearchItems.length) {
  695. // Update searchTerm, otherwise <SearchDropdown> will have wrong state
  696. // (e.g. if you delete a query, the last letter will be highlighted if `searchTerm`
  697. // does not get updated)
  698. this.setState({searchTerm: query});
  699. const tagKeys = this.getTagKeys('');
  700. const recentSearches = await this.getRecentSearches();
  701. this.updateAutoCompleteState(tagKeys, recentSearches ?? [], '', 'tag-key');
  702. return;
  703. }
  704. // cursor on whitespace show default "help" search terms
  705. this.setState({searchTerm: ''});
  706. this.updateAutoCompleteState(defaultSearchItems, defaultRecentItems, '', 'default');
  707. return;
  708. }
  709. const last = terms.pop() ?? '';
  710. let autoCompleteItems: SearchItem[];
  711. let matchValue: string;
  712. let tagName: string;
  713. const index = last.indexOf(':');
  714. if (index === -1) {
  715. // No colon present; must still be deciding key
  716. matchValue = last.replace(new RegExp(`^${NEGATION_OPERATOR}`), '');
  717. autoCompleteItems = this.getTagKeys(matchValue);
  718. const recentSearches = await this.getRecentSearches();
  719. this.setState({searchTerm: matchValue});
  720. this.updateAutoCompleteState(
  721. autoCompleteItems,
  722. recentSearches ?? [],
  723. matchValue,
  724. 'tag-key'
  725. );
  726. return;
  727. }
  728. const {prepareQuery, excludeEnvironment} = this.props;
  729. const supportedTags = this.props.supportedTags ?? {};
  730. // TODO(billy): Better parsing for these examples
  731. //
  732. // > sentry:release:
  733. // > url:"http://with/colon"
  734. tagName = last.slice(0, index);
  735. // e.g. given "!gpu" we want "gpu"
  736. tagName = tagName.replace(new RegExp(`^${NEGATION_OPERATOR}`), '');
  737. query = last.slice(index + 1);
  738. const preparedQuery =
  739. typeof prepareQuery === 'function' ? prepareQuery(query) : query;
  740. // filter existing items immediately, until API can return
  741. // with actual tag value results
  742. const filteredSearchGroups = !preparedQuery
  743. ? this.state.searchGroups
  744. : this.state.searchGroups.filter(
  745. item => item.value && item.value.indexOf(preparedQuery) !== -1
  746. );
  747. this.setState({
  748. searchTerm: query,
  749. searchGroups: filteredSearchGroups,
  750. });
  751. const tag = supportedTags[tagName];
  752. if (!tag) {
  753. this.updateAutoCompleteState([], [], tagName, 'invalid-tag');
  754. return;
  755. }
  756. // Ignore the environment tag if the feature is active and
  757. // excludeEnvironment = true
  758. if (excludeEnvironment && tagName === 'environment') {
  759. return;
  760. }
  761. const fetchTagValuesFn =
  762. tag.key === 'firstRelease'
  763. ? this.getReleases
  764. : tag.predefined
  765. ? this.getPredefinedTagValues
  766. : this.getTagValues;
  767. const [tagValues, recentSearches] = await Promise.all([
  768. fetchTagValuesFn(tag, preparedQuery),
  769. this.getRecentSearches(),
  770. ]);
  771. this.updateAutoCompleteState(
  772. tagValues ?? [],
  773. recentSearches ?? [],
  774. tag.key,
  775. 'tag-value'
  776. );
  777. return;
  778. };
  779. isDefaultDropdownItem = (item: SearchItem) => item && item.type === 'default';
  780. /**
  781. * Updates autocomplete dropdown items and autocomplete index state
  782. *
  783. * @param searchItems List of search item objects with keys: title, desc, value
  784. * @param recentSearchItems List of recent search items, same format as searchItem
  785. * @param tagName The current tag name in scope
  786. * @param type Defines the type/state of the dropdown menu items
  787. */
  788. updateAutoCompleteState = (
  789. searchItems: SearchItem[],
  790. recentSearchItems: SearchItem[],
  791. tagName: string,
  792. type: ItemType
  793. ) => {
  794. const {hasRecentSearches, maxSearchItems, maxQueryLength} = this.props;
  795. const query = this.state.query;
  796. const queryCharsLeft =
  797. maxQueryLength && query ? maxQueryLength - query.length : undefined;
  798. this.setState(
  799. createSearchGroups(
  800. searchItems,
  801. hasRecentSearches ? recentSearchItems : undefined,
  802. tagName,
  803. type,
  804. maxSearchItems,
  805. queryCharsLeft
  806. )
  807. );
  808. };
  809. onTogglePinnedSearch = async (evt: React.MouseEvent) => {
  810. const {
  811. api,
  812. location,
  813. organization,
  814. savedSearchType,
  815. hasPinnedSearch,
  816. pinnedSearch,
  817. sort,
  818. } = this.props;
  819. evt.preventDefault();
  820. evt.stopPropagation();
  821. if (savedSearchType === undefined || !hasPinnedSearch) {
  822. return;
  823. }
  824. // eslint-disable-next-line no-unused-vars
  825. const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
  826. trackAnalyticsEvent({
  827. eventKey: 'search.pin',
  828. eventName: 'Search: Pin',
  829. organization_id: organization.id,
  830. action: !!pinnedSearch ? 'unpin' : 'pin',
  831. search_type: savedSearchType === 0 ? 'issues' : 'events',
  832. query: pinnedSearch?.query ?? this.state.query,
  833. });
  834. if (!!pinnedSearch) {
  835. unpinSearch(api, organization.slug, savedSearchType, pinnedSearch).then(() => {
  836. browserHistory.push({
  837. ...location,
  838. pathname: `/organizations/${organization.slug}/issues/`,
  839. query: {
  840. ...currentQuery,
  841. query: pinnedSearch.query,
  842. sort: pinnedSearch.sort,
  843. },
  844. });
  845. });
  846. return;
  847. }
  848. const resp = await pinSearch(
  849. api,
  850. organization.slug,
  851. savedSearchType,
  852. removeSpace(this.state.query),
  853. sort
  854. );
  855. if (!resp || !resp.id) {
  856. return;
  857. }
  858. browserHistory.push({
  859. ...location,
  860. pathname: `/organizations/${organization.slug}/issues/searches/${resp.id}/`,
  861. query: currentQuery,
  862. });
  863. };
  864. onAutoComplete = (replaceText: string, item: SearchItem) => {
  865. if (item.type === 'recent-search') {
  866. trackAnalyticsEvent({
  867. eventKey: 'search.searched',
  868. eventName: 'Search: Performed search',
  869. organization_id: this.props.organization.id,
  870. query: replaceText,
  871. source: this.props.savedSearchType === 0 ? 'issues' : 'events',
  872. search_source: 'recent_search',
  873. });
  874. this.setState({query: replaceText}, () => {
  875. // Propagate onSearch and save to recent searches
  876. this.doSearch();
  877. });
  878. return;
  879. }
  880. const cursor = this.getCursorPosition();
  881. const query = this.state.query;
  882. const lastTermIndex = SmartSearchBar.getLastTermIndex(query, cursor);
  883. const terms = SmartSearchBar.getQueryTerms(query, lastTermIndex);
  884. let newQuery: string;
  885. // If not postfixed with : (tag value), add trailing space
  886. replaceText += item.type !== 'tag-value' || cursor < query.length ? '' : ' ';
  887. const isNewTerm = query.charAt(query.length - 1) === ' ' && item.type !== 'tag-value';
  888. if (!terms) {
  889. newQuery = replaceText;
  890. } else if (isNewTerm) {
  891. newQuery = `${query}${replaceText}`;
  892. } else {
  893. const last = terms.pop() ?? '';
  894. newQuery = query.slice(0, lastTermIndex); // get text preceding last term
  895. const prefix = last.startsWith(NEGATION_OPERATOR) ? NEGATION_OPERATOR : '';
  896. // newQuery is all the terms up to the current term: "... <term>:"
  897. // replaceText should be the selected value
  898. if (last.indexOf(':') > -1) {
  899. let replacement = `:${replaceText}`;
  900. // The user tag often contains : within its value and we need to quote it.
  901. if (last.startsWith('user:')) {
  902. const colonIndex = replaceText.indexOf(':');
  903. if (colonIndex > -1) {
  904. replacement = `:"${replaceText.trim()}"`;
  905. }
  906. }
  907. // tag key present: replace everything after colon with replaceText
  908. newQuery = newQuery.replace(/\:"[^"]*"?$|\:\S*$/, replacement);
  909. } else {
  910. // no tag key present: replace last token with replaceText
  911. newQuery = newQuery.replace(/\S+$/, `${prefix}${replaceText}`);
  912. }
  913. newQuery = newQuery.concat(query.slice(lastTermIndex));
  914. }
  915. this.setState({query: newQuery}, () => {
  916. // setting a new input value will lose focus; restore it
  917. if (this.searchInput.current) {
  918. this.searchInput.current.focus();
  919. }
  920. // then update the autocomplete box with new items
  921. this.updateAutoCompleteItems();
  922. this.props.onChange?.(newQuery, new MouseEvent('click') as any);
  923. });
  924. };
  925. render() {
  926. const {
  927. className,
  928. dropdownClassName,
  929. organization,
  930. hasPinnedSearch,
  931. hasSearchBuilder,
  932. canCreateSavedSearch,
  933. pinnedSearch,
  934. placeholder,
  935. disabled,
  936. useFormWrapper,
  937. onSidebarToggle,
  938. inlineLabel,
  939. sort,
  940. maxQueryLength,
  941. } = this.props;
  942. const pinTooltip = !!pinnedSearch ? t('Unpin this search') : t('Pin this search');
  943. const pinIcon = <IconPin isSolid={!!pinnedSearch} size="xs" />;
  944. const hasQuery = !!this.state.query;
  945. const input = (
  946. <React.Fragment>
  947. <StyledInput
  948. type="text"
  949. placeholder={placeholder}
  950. id="smart-search-input"
  951. name="query"
  952. ref={this.searchInput}
  953. autoComplete="off"
  954. value={this.state.query}
  955. onFocus={this.onQueryFocus}
  956. onBlur={this.onQueryBlur}
  957. onKeyUp={this.onKeyUp}
  958. onKeyDown={this.onKeyDown}
  959. onChange={this.onQueryChange}
  960. onClick={this.onInputClick}
  961. disabled={disabled}
  962. maxLength={maxQueryLength}
  963. />
  964. {(this.state.loading || this.state.searchGroups.length > 0) && (
  965. <DropdownWrapper visible={this.state.dropdownVisible}>
  966. <SearchDropdown
  967. className={dropdownClassName}
  968. items={this.state.searchGroups}
  969. onClick={this.onAutoComplete}
  970. loading={this.state.loading}
  971. searchSubstring={this.state.searchTerm}
  972. />
  973. </DropdownWrapper>
  974. )}
  975. </React.Fragment>
  976. );
  977. return (
  978. <Container className={className} isOpen={this.state.dropdownVisible}>
  979. <SearchLabel htmlFor="smart-search-input" aria-label={t('Search events')}>
  980. <IconSearch />
  981. {inlineLabel}
  982. </SearchLabel>
  983. {useFormWrapper ? (
  984. <StyledForm onSubmit={this.onSubmit}>{input}</StyledForm>
  985. ) : (
  986. input
  987. )}
  988. <StyledButtonBar gap={0.5}>
  989. {this.state.query !== '' && (
  990. <InputButton
  991. type="button"
  992. title={t('Clear search')}
  993. borderless
  994. aria-label="Clear search"
  995. size="zero"
  996. tooltipProps={{
  997. containerDisplayMode: 'inline-flex',
  998. }}
  999. onClick={this.clearSearch}
  1000. >
  1001. <IconClose size="xs" />
  1002. </InputButton>
  1003. )}
  1004. {hasPinnedSearch && (
  1005. <ClassNames>
  1006. {({css}) => (
  1007. <InputButton
  1008. type="button"
  1009. title={pinTooltip}
  1010. borderless
  1011. disabled={!hasQuery}
  1012. aria-label={pinTooltip}
  1013. size="zero"
  1014. tooltipProps={{
  1015. containerDisplayMode: 'inline-flex',
  1016. className: css`
  1017. ${getMediaQuery(commonTheme.breakpoints[1], 'none')}
  1018. `,
  1019. }}
  1020. onClick={this.onTogglePinnedSearch}
  1021. collapseIntoEllipsisMenu={1}
  1022. isActive={!!pinnedSearch}
  1023. icon={pinIcon}
  1024. />
  1025. )}
  1026. </ClassNames>
  1027. )}
  1028. {canCreateSavedSearch && (
  1029. <ClassNames>
  1030. {({css}) => (
  1031. <CreateSavedSearchButton
  1032. query={this.state.query}
  1033. sort={sort}
  1034. organization={organization}
  1035. withTooltip
  1036. iconOnly
  1037. buttonClassName={css`
  1038. ${getInputButtonStyles({
  1039. collapseIntoEllipsisMenu: 2,
  1040. })}
  1041. `}
  1042. tooltipClassName={css`
  1043. ${getMediaQuery(commonTheme.breakpoints[2], 'none')}
  1044. `}
  1045. />
  1046. )}
  1047. </ClassNames>
  1048. )}
  1049. {hasSearchBuilder && (
  1050. <ClassNames>
  1051. {({css}) => (
  1052. <InputButton
  1053. title={t('Toggle search builder')}
  1054. borderless
  1055. size="zero"
  1056. tooltipProps={{
  1057. containerDisplayMode: 'inline-flex',
  1058. className: css`
  1059. ${getMediaQuery(commonTheme.breakpoints[2], 'none')}
  1060. `,
  1061. }}
  1062. collapseIntoEllipsisMenu={2}
  1063. aria-label={t('Toggle search builder')}
  1064. onClick={onSidebarToggle}
  1065. icon={<IconSliders size="xs" />}
  1066. />
  1067. )}
  1068. </ClassNames>
  1069. )}
  1070. {(hasPinnedSearch || canCreateSavedSearch || hasSearchBuilder) && (
  1071. <StyledDropdownLink
  1072. anchorRight
  1073. caret={false}
  1074. title={
  1075. <EllipsisButton
  1076. size="zero"
  1077. borderless
  1078. tooltipProps={{
  1079. containerDisplayMode: 'flex',
  1080. }}
  1081. type="button"
  1082. aria-label={t('Show more')}
  1083. icon={<VerticalEllipsisIcon size="xs" />}
  1084. />
  1085. }
  1086. >
  1087. {hasPinnedSearch && (
  1088. <DropdownElement
  1089. showBelowMediaQuery={1}
  1090. data-test-id="pin-icon"
  1091. onClick={this.onTogglePinnedSearch}
  1092. >
  1093. {pinIcon}
  1094. {!!pinnedSearch ? t('Unpin Search') : t('Pin Search')}
  1095. </DropdownElement>
  1096. )}
  1097. {canCreateSavedSearch && (
  1098. <ThemedCreateSavedSearchButton
  1099. query={this.state.query}
  1100. organization={organization}
  1101. sort={sort}
  1102. />
  1103. )}
  1104. {hasSearchBuilder && (
  1105. <DropdownElement showBelowMediaQuery={2} last onClick={onSidebarToggle}>
  1106. <IconSliders size="xs" />
  1107. {t('Toggle sidebar')}
  1108. </DropdownElement>
  1109. )}
  1110. </StyledDropdownLink>
  1111. )}
  1112. </StyledButtonBar>
  1113. </Container>
  1114. );
  1115. }
  1116. }
  1117. const SmartSearchBarContainer = createReactClass<Props>({
  1118. displayName: 'SmartSearchBarContainer',
  1119. mixins: [Reflux.listenTo(MemberListStore, 'onMemberListStoreChange') as any],
  1120. getInitialState() {
  1121. return {
  1122. members: MemberListStore.getAll(),
  1123. };
  1124. },
  1125. onMemberListStoreChange(members: any) {
  1126. this.setState({members}, this.updateAutoCompleteItems);
  1127. },
  1128. render() {
  1129. // SmartSearchBar doesn't use members, but we forward it to cause a re-render.
  1130. return <SmartSearchBar {...this.props} members={this.state.members} />;
  1131. },
  1132. });
  1133. export default withApi(withRouter(withOrganization(SmartSearchBarContainer)));
  1134. export {SmartSearchBar};
  1135. const Container = styled('div')<{isOpen: boolean}>`
  1136. border: 1px solid ${p => p.theme.border};
  1137. border-radius: ${p =>
  1138. p.isOpen
  1139. ? `${p.theme.borderRadius} ${p.theme.borderRadius} 0 0`
  1140. : p.theme.borderRadius};
  1141. /* match button height */
  1142. height: 40px;
  1143. box-shadow: inset ${p => p.theme.dropShadowLight};
  1144. background: ${p => p.theme.background};
  1145. position: relative;
  1146. display: flex;
  1147. .show-sidebar & {
  1148. background: ${p => p.theme.backgroundSecondary};
  1149. }
  1150. `;
  1151. const DropdownWrapper = styled('div')<{visible: boolean}>`
  1152. display: ${p => (p.visible ? 'block' : 'none')};
  1153. `;
  1154. const StyledForm = styled('form')`
  1155. flex-grow: 1;
  1156. `;
  1157. const StyledInput = styled('input')`
  1158. color: ${p => p.theme.textColor};
  1159. background: transparent;
  1160. border: 0;
  1161. outline: none;
  1162. font-size: ${p => p.theme.fontSizeMedium};
  1163. width: 100%;
  1164. height: 40px;
  1165. line-height: 40px;
  1166. padding: 0 0 0 ${space(1)};
  1167. &::placeholder {
  1168. color: ${p => p.theme.formPlaceholder};
  1169. }
  1170. &:focus {
  1171. border-color: ${p => p.theme.border};
  1172. border-bottom-right-radius: 0;
  1173. }
  1174. .show-sidebar & {
  1175. color: ${p => p.theme.disabled};
  1176. }
  1177. `;
  1178. const InputButton = styled(Button)`
  1179. ${getInputButtonStyles}
  1180. `;
  1181. const StyledDropdownLink = styled(DropdownLink)`
  1182. display: none;
  1183. @media (max-width: ${commonTheme.breakpoints[2]}) {
  1184. display: flex;
  1185. }
  1186. `;
  1187. const DropdownElement = styled('a')<Omit<DropdownElementStylesProps, 'theme'>>`
  1188. ${getDropdownElementStyles}
  1189. `;
  1190. const StyledButtonBar = styled(ButtonBar)`
  1191. margin-right: ${space(1)};
  1192. `;
  1193. const EllipsisButton = styled(InputButton)`
  1194. /*
  1195. * this is necessary because DropdownLink wraps the button in an unstyled
  1196. * span
  1197. */
  1198. margin: 6px 0 0 0;
  1199. `;
  1200. const VerticalEllipsisIcon = styled(IconEllipsis)`
  1201. transform: rotate(90deg);
  1202. `;
  1203. const SearchLabel = styled('label')`
  1204. display: flex;
  1205. align-items: center;
  1206. margin: 0;
  1207. padding-left: ${space(1)};
  1208. color: ${p => p.theme.gray300};
  1209. `;