index.tsx 33 KB

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