index.tsx 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748
  1. import {Component, createRef} from 'react';
  2. import TextareaAutosize from 'react-autosize-textarea';
  3. // eslint-disable-next-line no-restricted-imports
  4. import {withRouter, WithRouterProps} from 'react-router';
  5. import isPropValid from '@emotion/is-prop-valid';
  6. import styled from '@emotion/styled';
  7. import * as Sentry from '@sentry/react';
  8. import debounce from 'lodash/debounce';
  9. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  10. import {fetchRecentSearches, saveRecentSearch} from 'sentry/actionCreators/savedSearches';
  11. import {Client} from 'sentry/api';
  12. import ButtonBar from 'sentry/components/buttonBar';
  13. import DropdownLink from 'sentry/components/dropdownLink';
  14. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  15. import {
  16. FilterType,
  17. ParseResult,
  18. parseSearch,
  19. TermOperator,
  20. Token,
  21. TokenResult,
  22. } from 'sentry/components/searchSyntax/parser';
  23. import HighlightQuery from 'sentry/components/searchSyntax/renderer';
  24. import {
  25. getKeyName,
  26. isWithinToken,
  27. treeResultLocator,
  28. } from 'sentry/components/searchSyntax/utils';
  29. import {
  30. DEFAULT_DEBOUNCE_DURATION,
  31. MAX_AUTOCOMPLETE_RELEASES,
  32. NEGATION_OPERATOR,
  33. } from 'sentry/constants';
  34. import {IconClose, IconEllipsis, IconSearch} from 'sentry/icons';
  35. import {t} from 'sentry/locale';
  36. import MemberListStore from 'sentry/stores/memberListStore';
  37. import space from 'sentry/styles/space';
  38. import {Organization, SavedSearchType, Tag, User} from 'sentry/types';
  39. import {defined} from 'sentry/utils';
  40. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  41. import {callIfFunction} from 'sentry/utils/callIfFunction';
  42. import getDynamicComponent from 'sentry/utils/getDynamicComponent';
  43. import withApi from 'sentry/utils/withApi';
  44. import withOrganization from 'sentry/utils/withOrganization';
  45. import {ActionButton} from './actions';
  46. import SearchDropdown from './searchDropdown';
  47. import SearchHotkeysListener from './searchHotkeysListener';
  48. import {ItemType, SearchGroup, SearchItem, Shortcut, ShortcutType} from './types';
  49. import {
  50. addSpace,
  51. createSearchGroups,
  52. generateOperatorEntryMap,
  53. getSearchGroupWithItemMarkedActive,
  54. getTagItemsFromKeys,
  55. getValidOps,
  56. removeSpace,
  57. shortcuts,
  58. } from './utils';
  59. const DROPDOWN_BLUR_DURATION = 200;
  60. /**
  61. * The max width in pixels of the search bar at which the buttons will
  62. * have overflowed into the dropdown.
  63. */
  64. const ACTION_OVERFLOW_WIDTH = 400;
  65. /**
  66. * Actions are moved to the overflow dropdown after each pixel step is reached.
  67. */
  68. const ACTION_OVERFLOW_STEPS = 75;
  69. const makeQueryState = (query: string) => ({
  70. query,
  71. // Anytime the query changes and it is not "" the dropdown should show
  72. showDropdown: true,
  73. parsedQuery: parseSearch(query),
  74. });
  75. const generateOpAutocompleteGroup = (
  76. validOps: readonly TermOperator[],
  77. tagName: string
  78. ): AutocompleteGroup => {
  79. const operatorMap = generateOperatorEntryMap(tagName);
  80. const operatorItems = validOps.map(op => operatorMap[op]);
  81. return {
  82. searchItems: operatorItems,
  83. recentSearchItems: undefined,
  84. tagName: '',
  85. type: ItemType.TAG_OPERATOR,
  86. };
  87. };
  88. const escapeValue = (value: string): string => {
  89. // Wrap in quotes if there is a space
  90. return value.includes(' ') || value.includes('"')
  91. ? `"${value.replace(/"/g, '\\"')}"`
  92. : value;
  93. };
  94. type ActionProps = {
  95. api: Client;
  96. /**
  97. * The organization
  98. */
  99. organization: Organization;
  100. /**
  101. * The current query
  102. */
  103. query: string;
  104. /**
  105. * Render the actions as a menu item
  106. */
  107. menuItemVariant?: boolean;
  108. /**
  109. * The saved search type passed to the search bar
  110. */
  111. savedSearchType?: SavedSearchType;
  112. };
  113. type ActionBarItem = {
  114. /**
  115. * The action component to render
  116. */
  117. Action: React.ComponentType<ActionProps>;
  118. /**
  119. * Name of the action
  120. */
  121. key: string;
  122. };
  123. type AutocompleteGroup = {
  124. recentSearchItems: SearchItem[] | undefined;
  125. searchItems: SearchItem[];
  126. tagName: string;
  127. type: ItemType;
  128. };
  129. type Props = WithRouterProps & {
  130. api: Client;
  131. organization: Organization;
  132. /**
  133. * Additional components to render as actions on the right of the search bar
  134. */
  135. actionBarItems?: ActionBarItem[];
  136. className?: string;
  137. defaultQuery?: string;
  138. /**
  139. * Search items to display when there's no tag key. Is a tuple of search
  140. * items and recent search items
  141. */
  142. defaultSearchItems?: [SearchItem[], SearchItem[]];
  143. /**
  144. * Disabled control (e.g. read-only)
  145. */
  146. disabled?: boolean;
  147. dropdownClassName?: string;
  148. /**
  149. * If true, excludes the environment tag from the autocompletion list. This
  150. * is because we don't want to treat environment as a tag in some places such
  151. * as the stream view where it is a top level concept
  152. */
  153. excludeEnvironment?: boolean;
  154. /**
  155. * A function to get documentation for a field
  156. */
  157. getFieldDoc?: (key: string) => React.ReactNode;
  158. /**
  159. * List user's recent searches
  160. */
  161. hasRecentSearches?: boolean;
  162. /**
  163. * Allows additional content to be played before the search bar and icon
  164. */
  165. inlineLabel?: React.ReactNode;
  166. /**
  167. * Maximum height for the search dropdown menu
  168. */
  169. maxMenuHeight?: number;
  170. /**
  171. * Used to enforce length on the query
  172. */
  173. maxQueryLength?: number;
  174. /**
  175. * Maximum number of search items to display or a falsey value for no
  176. * maximum
  177. */
  178. maxSearchItems?: number;
  179. /**
  180. * While the data is unused, this list of members can be updated to
  181. * trigger re-renders.
  182. */
  183. members?: User[];
  184. /**
  185. * Called when the search is blurred
  186. */
  187. onBlur?: (value: string) => void;
  188. /**
  189. * Called when the search input changes
  190. */
  191. onChange?: (value: string, e: React.ChangeEvent) => void;
  192. /**
  193. * Get a list of recent searches for the current query
  194. */
  195. onGetRecentSearches?: (query: string) => Promise<SearchItem[]>;
  196. /**
  197. * Get a list of tag values for the passed tag
  198. */
  199. onGetTagValues?: (tag: Tag, query: string, params: object) => Promise<string[]>;
  200. /**
  201. * Called on key down
  202. */
  203. onKeyDown?: (evt: React.KeyboardEvent<HTMLTextAreaElement>) => void;
  204. /**
  205. * Called when a recent search is saved
  206. */
  207. onSavedRecentSearch?: (query: string) => void;
  208. /**
  209. * Called when the user makes a search
  210. */
  211. onSearch?: (query: string) => void;
  212. /**
  213. * Input placeholder
  214. */
  215. placeholder?: string;
  216. /**
  217. * Prepare query value before filtering dropdown items
  218. */
  219. prepareQuery?: (query: string) => string;
  220. query?: string | null;
  221. /**
  222. * If this is defined, attempt to save search term scoped to the user and
  223. * the current org
  224. */
  225. savedSearchType?: SavedSearchType;
  226. /**
  227. * Indicates the usage of the search bar for analytics
  228. */
  229. searchSource?: string;
  230. /**
  231. * Type of supported tags
  232. */
  233. supportedTagType?: ItemType;
  234. /**
  235. * Map of tags
  236. */
  237. supportedTags?: {[key: string]: Tag};
  238. /**
  239. * Wrap the input with a form. Useful if search bar is used within a parent
  240. * form
  241. */
  242. useFormWrapper?: boolean;
  243. };
  244. type State = {
  245. /**
  246. * Index of the focused search item
  247. */
  248. activeSearchItem: number;
  249. flatSearchItems: SearchItem[];
  250. inputHasFocus: boolean;
  251. loading: boolean;
  252. /**
  253. * The number of actions that are not in the overflow menu.
  254. */
  255. numActionsVisible: number;
  256. /**
  257. * The query parsed into an AST. If the query fails to parse this will be
  258. * null.
  259. */
  260. parsedQuery: ParseResult | null;
  261. /**
  262. * The current search query in the input
  263. */
  264. query: string;
  265. searchGroups: SearchGroup[];
  266. /**
  267. * The current search term (or 'key') that that we will be showing
  268. * autocompletion for.
  269. */
  270. searchTerm: string;
  271. /**
  272. * Boolean indicating if dropdown should be shown
  273. */
  274. showDropdown: boolean;
  275. tags: Record<string, string>;
  276. /**
  277. * Indicates that we have a query that we've already determined not to have
  278. * any values. This is used to stop the autocompleter from querying if we
  279. * know we will find nothing.
  280. */
  281. noValueQuery?: string;
  282. /**
  283. * The query in the input since we last updated our autocomplete list.
  284. */
  285. previousQuery?: string;
  286. };
  287. class SmartSearchBar extends Component<Props, State> {
  288. static defaultProps = {
  289. defaultQuery: '',
  290. query: null,
  291. onSearch: function () {},
  292. excludeEnvironment: false,
  293. placeholder: t('Search for events, users, tags, and more'),
  294. supportedTags: {},
  295. defaultSearchItems: [[], []],
  296. useFormWrapper: true,
  297. savedSearchType: SavedSearchType.ISSUE,
  298. };
  299. state: State = {
  300. query: this.initialQuery,
  301. showDropdown: false,
  302. parsedQuery: parseSearch(this.initialQuery),
  303. searchTerm: '',
  304. searchGroups: [],
  305. flatSearchItems: [],
  306. activeSearchItem: -1,
  307. tags: {},
  308. inputHasFocus: false,
  309. loading: false,
  310. numActionsVisible: this.props.actionBarItems?.length ?? 0,
  311. };
  312. componentDidMount() {
  313. if (!window.ResizeObserver) {
  314. return;
  315. }
  316. if (this.containerRef.current === null) {
  317. return;
  318. }
  319. this.inputResizeObserver = new ResizeObserver(this.updateActionsVisible);
  320. this.inputResizeObserver.observe(this.containerRef.current);
  321. }
  322. componentDidUpdate(prevProps: Props) {
  323. const {query} = this.props;
  324. const {query: lastQuery} = prevProps;
  325. if (query !== lastQuery && (defined(query) || defined(lastQuery))) {
  326. // eslint-disable-next-line react/no-did-update-set-state
  327. this.setState(makeQueryState(addSpace(query ?? undefined)));
  328. }
  329. }
  330. componentWillUnmount() {
  331. this.inputResizeObserver?.disconnect();
  332. window.clearTimeout(this.blurTimeout);
  333. }
  334. get initialQuery() {
  335. const {query, defaultQuery} = this.props;
  336. return query !== null ? addSpace(query) : defaultQuery ?? '';
  337. }
  338. /**
  339. * Tracks the dropdown blur
  340. */
  341. blurTimeout: number | undefined = undefined;
  342. /**
  343. * Ref to the search element itself
  344. */
  345. searchInput = createRef<HTMLTextAreaElement>();
  346. /**
  347. * Ref to the search container
  348. */
  349. containerRef = createRef<HTMLDivElement>();
  350. /**
  351. * Used to determine when actions should be moved to the action overflow menu
  352. */
  353. inputResizeObserver: ResizeObserver | null = null;
  354. /**
  355. * Updates the numActionsVisible count as the search bar is resized
  356. */
  357. updateActionsVisible = (entries: ResizeObserverEntry[]) => {
  358. if (entries.length === 0) {
  359. return;
  360. }
  361. const entry = entries[0];
  362. const {width} = entry.contentRect;
  363. const actionCount = this.props.actionBarItems?.length ?? 0;
  364. const numActionsVisible = Math.min(
  365. actionCount,
  366. Math.floor(Math.max(0, width - ACTION_OVERFLOW_WIDTH) / ACTION_OVERFLOW_STEPS)
  367. );
  368. if (this.state.numActionsVisible === numActionsVisible) {
  369. return;
  370. }
  371. this.setState({numActionsVisible});
  372. };
  373. blur() {
  374. if (!this.searchInput.current) {
  375. return;
  376. }
  377. this.searchInput.current.blur();
  378. }
  379. async doSearch() {
  380. this.blur();
  381. if (!this.hasValidSearch) {
  382. return;
  383. }
  384. const query = removeSpace(this.state.query);
  385. const {
  386. onSearch,
  387. onSavedRecentSearch,
  388. api,
  389. organization,
  390. savedSearchType,
  391. searchSource,
  392. } = this.props;
  393. trackAdvancedAnalyticsEvent('search.searched', {
  394. organization,
  395. query,
  396. search_type: savedSearchType === 0 ? 'issues' : 'events',
  397. search_source: searchSource,
  398. });
  399. callIfFunction(onSearch, query);
  400. // Only save recent search query if we have a savedSearchType (also 0 is a valid value)
  401. // Do not save empty string queries (i.e. if they clear search)
  402. if (typeof savedSearchType === 'undefined' || !query) {
  403. return;
  404. }
  405. try {
  406. await saveRecentSearch(api, organization.slug, savedSearchType, query);
  407. if (onSavedRecentSearch) {
  408. onSavedRecentSearch(query);
  409. }
  410. } catch (err) {
  411. // Silently capture errors if it fails to save
  412. Sentry.captureException(err);
  413. }
  414. }
  415. moveToNextToken = (filterTokens: TokenResult<Token.Filter>[]) => {
  416. const token = this.cursorToken;
  417. if (this.searchInput.current && filterTokens.length > 0) {
  418. this.searchInput.current.focus();
  419. let offset = filterTokens[0].location.end.offset;
  420. if (token) {
  421. const tokenIndex = filterTokens.findIndex(tok => tok === token);
  422. if (tokenIndex !== -1 && tokenIndex + 1 < filterTokens.length) {
  423. offset = filterTokens[tokenIndex + 1].location.end.offset;
  424. }
  425. }
  426. this.searchInput.current.selectionStart = offset;
  427. this.searchInput.current.selectionEnd = offset;
  428. this.updateAutoCompleteItems();
  429. }
  430. };
  431. deleteToken = () => {
  432. const {query} = this.state;
  433. const token = this.cursorToken ?? undefined;
  434. const filterTokens = this.filterTokens;
  435. const hasExecCommand = typeof document.execCommand === 'function';
  436. if (token && filterTokens.length > 0) {
  437. const index = filterTokens.findIndex(tok => tok === token) ?? -1;
  438. const newQuery =
  439. // We trim to remove any remaining spaces
  440. query.slice(0, token.location.start.offset).trim() +
  441. (index > 0 && index < filterTokens.length - 1 ? ' ' : '') +
  442. query.slice(token.location.end.offset).trim();
  443. if (this.searchInput.current) {
  444. // Only use exec command if exists
  445. this.searchInput.current.focus();
  446. this.searchInput.current.selectionStart = 0;
  447. this.searchInput.current.selectionEnd = query.length;
  448. // Because firefox doesn't support inserting an empty string, we insert a newline character instead
  449. // But because of this, only on firefox, if you delete the last token you won't be able to undo.
  450. if (
  451. (navigator.userAgent.toLowerCase().includes('firefox') &&
  452. newQuery.length === 0) ||
  453. !hasExecCommand ||
  454. !document.execCommand('insertText', false, newQuery)
  455. ) {
  456. // This will run either when newQuery is empty on firefox or when execCommand fails.
  457. this.updateQuery(newQuery);
  458. }
  459. }
  460. }
  461. };
  462. negateToken = () => {
  463. const {query} = this.state;
  464. const token = this.cursorToken ?? undefined;
  465. const hasExecCommand = typeof document.execCommand === 'function';
  466. if (token && token.type === Token.Filter) {
  467. if (token.negated) {
  468. if (this.searchInput.current) {
  469. this.searchInput.current.focus();
  470. const tokenCursorOffset = this.cursorPosition - token.key.location.start.offset;
  471. // Select the whole token so we can replace it.
  472. this.searchInput.current.selectionStart = token.location.start.offset;
  473. this.searchInput.current.selectionEnd = token.location.end.offset;
  474. // We can't call insertText with an empty string on Firefox, so we have to do this.
  475. if (
  476. !hasExecCommand ||
  477. !document.execCommand('insertText', false, token.text.slice(1))
  478. ) {
  479. // Fallback when execCommand fails
  480. const newQuery =
  481. query.slice(0, token.location.start.offset) +
  482. query.slice(token.key.location.start.offset);
  483. this.updateQuery(newQuery, this.cursorPosition - 1);
  484. }
  485. // Return the cursor to where it should be
  486. const newCursorPosition = token.location.start.offset + tokenCursorOffset;
  487. this.searchInput.current.selectionStart = newCursorPosition;
  488. this.searchInput.current.selectionEnd = newCursorPosition;
  489. }
  490. } else {
  491. if (this.searchInput.current) {
  492. this.searchInput.current.focus();
  493. const tokenCursorOffset = this.cursorPosition - token.key.location.start.offset;
  494. this.searchInput.current.selectionStart = token.location.start.offset;
  495. this.searchInput.current.selectionEnd = token.location.start.offset;
  496. if (!hasExecCommand || !document.execCommand('insertText', false, '!')) {
  497. // Fallback when execCommand fails
  498. const newQuery =
  499. query.slice(0, token.key.location.start.offset) +
  500. '!' +
  501. query.slice(token.key.location.start.offset);
  502. this.updateQuery(newQuery, this.cursorPosition + 1);
  503. }
  504. // Return the cursor to where it should be, +1 for the ! character we added
  505. const newCursorPosition = token.location.start.offset + tokenCursorOffset + 1;
  506. this.searchInput.current.selectionStart = newCursorPosition;
  507. this.searchInput.current.selectionEnd = newCursorPosition;
  508. }
  509. }
  510. }
  511. };
  512. runShortcut = (shortcut: Shortcut) => {
  513. const token = this.cursorToken;
  514. const filterTokens = this.filterTokens;
  515. const {shortcutType, canRunShortcut} = shortcut;
  516. if (canRunShortcut(token, this.filterTokens.length)) {
  517. switch (shortcutType) {
  518. case ShortcutType.Delete: {
  519. this.deleteToken();
  520. break;
  521. }
  522. case ShortcutType.Negate: {
  523. this.negateToken();
  524. break;
  525. }
  526. case ShortcutType.Next: {
  527. this.moveToNextToken(filterTokens);
  528. break;
  529. }
  530. case ShortcutType.Previous: {
  531. this.moveToNextToken(filterTokens.reverse());
  532. break;
  533. }
  534. default:
  535. break;
  536. }
  537. }
  538. };
  539. onSubmit = (evt: React.FormEvent) => {
  540. evt.preventDefault();
  541. this.doSearch();
  542. };
  543. clearSearch = () =>
  544. this.setState(makeQueryState(''), () =>
  545. callIfFunction(this.props.onSearch, this.state.query)
  546. );
  547. onQueryFocus = () => this.setState({inputHasFocus: true, showDropdown: true});
  548. onQueryBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
  549. // wait before closing dropdown in case blur was a result of clicking a
  550. // menu option
  551. const blurHandler = () => {
  552. this.blurTimeout = undefined;
  553. this.setState({inputHasFocus: false, showDropdown: false});
  554. callIfFunction(this.props.onBlur, e.target.value);
  555. };
  556. window.clearTimeout(this.blurTimeout);
  557. this.blurTimeout = window.setTimeout(blurHandler, DROPDOWN_BLUR_DURATION);
  558. };
  559. onQueryChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
  560. const query = evt.target.value.replace('\n', '');
  561. this.setState(makeQueryState(query), this.updateAutoCompleteItems);
  562. callIfFunction(this.props.onChange, evt.target.value, evt);
  563. };
  564. /**
  565. * Prevent pasting extra spaces from formatted text
  566. */
  567. onPaste = (evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
  568. // Cancel paste
  569. evt.preventDefault();
  570. // Get text representation of clipboard
  571. const text = evt.clipboardData.getData('text/plain').replace('\n', '').trim();
  572. // Create new query
  573. const currentQuery = this.state.query;
  574. const cursorPosStart = this.searchInput.current!.selectionStart;
  575. const cursorPosEnd = this.searchInput.current!.selectionEnd;
  576. const textBefore = currentQuery.substring(0, cursorPosStart);
  577. const textAfter = currentQuery.substring(cursorPosEnd, currentQuery.length);
  578. const mergedText = `${textBefore}${text}${textAfter}`;
  579. // Insert text manually
  580. this.setState(makeQueryState(mergedText), () => {
  581. this.updateAutoCompleteItems();
  582. // Update cursor position after updating text
  583. const newCursorPosition = cursorPosStart + text.length;
  584. this.searchInput.current!.selectionStart = newCursorPosition;
  585. this.searchInput.current!.selectionEnd = newCursorPosition;
  586. });
  587. callIfFunction(this.props.onChange, mergedText, evt);
  588. };
  589. onInputClick = () => this.updateAutoCompleteItems();
  590. /**
  591. * Handle keyboard navigation
  592. */
  593. onKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
  594. const {onKeyDown} = this.props;
  595. const {key} = evt;
  596. callIfFunction(onKeyDown, evt);
  597. const hasSearchGroups = this.state.searchGroups.length > 0;
  598. const isSelectingDropdownItems = this.state.activeSearchItem !== -1;
  599. if ((key === 'ArrowDown' || key === 'ArrowUp') && hasSearchGroups) {
  600. evt.preventDefault();
  601. const {flatSearchItems, activeSearchItem} = this.state;
  602. let searchGroups = [...this.state.searchGroups];
  603. const currIndex = isSelectingDropdownItems ? activeSearchItem : 0;
  604. const totalItems = flatSearchItems.length;
  605. // Move the selected index up/down
  606. const nextActiveSearchItem =
  607. key === 'ArrowUp'
  608. ? (currIndex - 1 + totalItems) % totalItems
  609. : isSelectingDropdownItems
  610. ? (currIndex + 1) % totalItems
  611. : 0;
  612. // Clear previous selection
  613. const prevItem = flatSearchItems[currIndex];
  614. searchGroups = getSearchGroupWithItemMarkedActive(searchGroups, prevItem, false);
  615. // Set new selection
  616. const activeItem = flatSearchItems[nextActiveSearchItem];
  617. searchGroups = getSearchGroupWithItemMarkedActive(searchGroups, activeItem, true);
  618. this.setState({searchGroups, activeSearchItem: nextActiveSearchItem});
  619. }
  620. if (
  621. (key === 'Tab' || key === 'Enter') &&
  622. isSelectingDropdownItems &&
  623. hasSearchGroups
  624. ) {
  625. evt.preventDefault();
  626. const {activeSearchItem, flatSearchItems} = this.state;
  627. const item = flatSearchItems[activeSearchItem];
  628. if (item) {
  629. if (item.callback) {
  630. item.callback();
  631. } else {
  632. this.onAutoComplete(item.value ?? '', item);
  633. }
  634. }
  635. return;
  636. }
  637. if (key === 'Enter' && !isSelectingDropdownItems) {
  638. this.doSearch();
  639. return;
  640. }
  641. const cursorToken = this.cursorToken;
  642. if (
  643. key === '[' &&
  644. cursorToken?.type === Token.Filter &&
  645. cursorToken.value.text.length === 0 &&
  646. isWithinToken(cursorToken.value, this.cursorPosition)
  647. ) {
  648. const {query} = this.state;
  649. evt.preventDefault();
  650. let clauseStart: null | number = null;
  651. let clauseEnd: null | number = null;
  652. // the new text that will exist between clauseStart and clauseEnd
  653. const replaceToken = '[]';
  654. const location = cursorToken.value.location;
  655. const keyLocation = cursorToken.key.location;
  656. // Include everything after the ':'
  657. clauseStart = keyLocation.end.offset + 1;
  658. clauseEnd = location.end.offset + 1;
  659. const beforeClause = query.substring(0, clauseStart);
  660. let endClause = query.substring(clauseEnd);
  661. // Add space before next clause if it exists
  662. if (endClause) {
  663. endClause = ` ${endClause}`;
  664. }
  665. const newQuery = `${beforeClause}${replaceToken}${endClause}`;
  666. // Place cursor between inserted brackets
  667. this.updateQuery(newQuery, beforeClause.length + replaceToken.length - 1);
  668. return;
  669. }
  670. };
  671. onKeyUp = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
  672. if (evt.key === 'ArrowLeft' || evt.key === 'ArrowRight') {
  673. this.updateAutoCompleteItems();
  674. }
  675. // Other keys are managed at onKeyDown function
  676. if (evt.key !== 'Escape') {
  677. return;
  678. }
  679. evt.preventDefault();
  680. if (!this.state.showDropdown) {
  681. this.blur();
  682. return;
  683. }
  684. const {flatSearchItems, activeSearchItem} = this.state;
  685. const isSelectingDropdownItems = this.state.activeSearchItem > -1;
  686. let searchGroups = [...this.state.searchGroups];
  687. if (isSelectingDropdownItems) {
  688. searchGroups = getSearchGroupWithItemMarkedActive(
  689. searchGroups,
  690. flatSearchItems[activeSearchItem],
  691. false
  692. );
  693. }
  694. this.setState({
  695. activeSearchItem: -1,
  696. showDropdown: false,
  697. searchGroups,
  698. });
  699. };
  700. /**
  701. * Check if any filters are invalid within the search query
  702. */
  703. get hasValidSearch() {
  704. const {parsedQuery} = this.state;
  705. // If we fail to parse be optimistic that it's valid
  706. if (parsedQuery === null) {
  707. return true;
  708. }
  709. return treeResultLocator<boolean>({
  710. tree: parsedQuery,
  711. noResultValue: true,
  712. visitorTest: ({token, returnResult, skipToken}) =>
  713. token.type !== Token.Filter
  714. ? null
  715. : token.invalid
  716. ? returnResult(false)
  717. : skipToken,
  718. });
  719. }
  720. /**
  721. * Get the active filter or free text actively focused.
  722. */
  723. get cursorToken() {
  724. const matchedTokens = [Token.Filter, Token.FreeText] as const;
  725. return this.findTokensAtCursor(matchedTokens);
  726. }
  727. /**
  728. * Get the active parsed text value
  729. */
  730. get cursorValue() {
  731. const matchedTokens = [Token.ValueText] as const;
  732. return this.findTokensAtCursor(matchedTokens);
  733. }
  734. /**
  735. * Get the current cursor position within the input
  736. */
  737. get cursorPosition() {
  738. if (!this.searchInput.current) {
  739. return -1;
  740. }
  741. // No cursor position when the input loses focus. This is important for
  742. // updating the search highlighters active state
  743. if (!this.state.inputHasFocus) {
  744. return -1;
  745. }
  746. return this.searchInput.current.selectionStart ?? -1;
  747. }
  748. get filterTokens(): TokenResult<Token.Filter>[] {
  749. return (this.state.parsedQuery?.filter(tok => tok.type === Token.Filter) ??
  750. []) as TokenResult<Token.Filter>[];
  751. }
  752. /**
  753. * Finds tokens that exist at the current cursor position
  754. * @param matchedTokens acceptable list of tokens
  755. */
  756. findTokensAtCursor<T extends readonly Token[]>(matchedTokens: T) {
  757. const {parsedQuery} = this.state;
  758. if (parsedQuery === null) {
  759. return null;
  760. }
  761. const cursor = this.cursorPosition;
  762. return treeResultLocator<TokenResult<T[number]> | null>({
  763. tree: parsedQuery,
  764. noResultValue: null,
  765. visitorTest: ({token, returnResult, skipToken}) =>
  766. !matchedTokens.includes(token.type)
  767. ? null
  768. : isWithinToken(token, cursor)
  769. ? returnResult(token)
  770. : skipToken,
  771. });
  772. }
  773. /**
  774. * Returns array of possible key values that substring match `query`
  775. */
  776. getTagKeys(query: string): [SearchItem[], ItemType] {
  777. const {prepareQuery, supportedTagType, getFieldDoc} = this.props;
  778. const supportedTags = this.props.supportedTags ?? {};
  779. let tagKeys = Object.keys(supportedTags);
  780. if (query) {
  781. const preparedQuery =
  782. typeof prepareQuery === 'function' ? prepareQuery(query) : query;
  783. tagKeys = tagKeys.filter(key => key.indexOf(preparedQuery) > -1);
  784. }
  785. // If the environment feature is active and excludeEnvironment = true
  786. // then remove the environment key
  787. if (this.props.excludeEnvironment) {
  788. tagKeys = tagKeys.filter(key => key !== 'environment');
  789. }
  790. const tagItems = getTagItemsFromKeys(tagKeys, supportedTags, getFieldDoc);
  791. return [tagItems, supportedTagType ?? ItemType.TAG_KEY];
  792. }
  793. /**
  794. * Returns array of tag values that substring match `query`; invokes `callback`
  795. * with data when ready
  796. */
  797. getTagValues = debounce(
  798. async (tag: Tag, query: string) => {
  799. // Strip double quotes if there are any
  800. query = query.replace(/"/g, '').trim();
  801. if (!this.props.onGetTagValues) {
  802. return [];
  803. }
  804. if (
  805. this.state.noValueQuery !== undefined &&
  806. query.startsWith(this.state.noValueQuery)
  807. ) {
  808. return [];
  809. }
  810. const {location} = this.props;
  811. const endpointParams = normalizeDateTimeParams(location.query);
  812. this.setState({loading: true});
  813. let values: string[] = [];
  814. try {
  815. values = await this.props.onGetTagValues(tag, query, endpointParams);
  816. this.setState({loading: false});
  817. } catch (err) {
  818. this.setState({loading: false});
  819. Sentry.captureException(err);
  820. return [];
  821. }
  822. if (tag.key === 'release:' && !values.includes('latest')) {
  823. values.unshift('latest');
  824. }
  825. const noValueQuery = values.length === 0 && query.length > 0 ? query : undefined;
  826. this.setState({noValueQuery});
  827. return values.map(value => {
  828. const escapedValue = escapeValue(value);
  829. return {
  830. value: escapedValue,
  831. desc: escapedValue,
  832. type: ItemType.TAG_VALUE,
  833. };
  834. });
  835. },
  836. DEFAULT_DEBOUNCE_DURATION,
  837. {leading: true}
  838. );
  839. /**
  840. * Returns array of tag values that substring match `query`; invokes `callback`
  841. * with results
  842. */
  843. getPredefinedTagValues = (tag: Tag, query: string): SearchItem[] =>
  844. (tag.values ?? [])
  845. .filter(value => value.indexOf(query) > -1)
  846. .map((value, i) => {
  847. const escapedValue = escapeValue(value);
  848. return {
  849. value: escapedValue,
  850. desc: escapedValue,
  851. type: ItemType.TAG_VALUE,
  852. ignoreMaxSearchItems: tag.maxSuggestedValues
  853. ? i < tag.maxSuggestedValues
  854. : false,
  855. };
  856. });
  857. /**
  858. * Get recent searches
  859. */
  860. getRecentSearches = debounce(
  861. async () => {
  862. const {savedSearchType, hasRecentSearches, onGetRecentSearches} = this.props;
  863. // `savedSearchType` can be 0
  864. if (!defined(savedSearchType) || !hasRecentSearches) {
  865. return [];
  866. }
  867. const fetchFn = onGetRecentSearches || this.fetchRecentSearches;
  868. return await fetchFn(this.state.query);
  869. },
  870. DEFAULT_DEBOUNCE_DURATION,
  871. {leading: true}
  872. );
  873. fetchRecentSearches = async (fullQuery: string): Promise<SearchItem[]> => {
  874. const {api, organization, savedSearchType} = this.props;
  875. if (savedSearchType === undefined) {
  876. return [];
  877. }
  878. try {
  879. const recentSearches: any[] = await fetchRecentSearches(
  880. api,
  881. organization.slug,
  882. savedSearchType,
  883. fullQuery
  884. );
  885. // If `recentSearches` is undefined or not an array, the function will
  886. // return an array anyway
  887. return recentSearches.map(searches => ({
  888. desc: searches.query,
  889. value: searches.query,
  890. type: ItemType.RECENT_SEARCH,
  891. }));
  892. } catch (e) {
  893. Sentry.captureException(e);
  894. }
  895. return [];
  896. };
  897. getReleases = debounce(
  898. async (tag: Tag, query: string) => {
  899. const releasePromise = this.fetchReleases(query);
  900. const tags = this.getPredefinedTagValues(tag, query);
  901. const tagValues = tags.map<SearchItem>(v => ({
  902. ...v,
  903. type: ItemType.FIRST_RELEASE,
  904. }));
  905. const releases = await releasePromise;
  906. const releaseValues = releases.map<SearchItem>((r: any) => ({
  907. value: r.shortVersion,
  908. desc: r.shortVersion,
  909. type: ItemType.FIRST_RELEASE,
  910. }));
  911. return [...tagValues, ...releaseValues];
  912. },
  913. DEFAULT_DEBOUNCE_DURATION,
  914. {leading: true}
  915. );
  916. /**
  917. * Fetches latest releases from a organization/project. Returns an empty array
  918. * if an error is encountered.
  919. */
  920. fetchReleases = async (releaseVersion: string): Promise<any[]> => {
  921. const {api, location, organization} = this.props;
  922. const project = location && location.query ? location.query.projectId : undefined;
  923. const url = `/organizations/${organization.slug}/releases/`;
  924. const fetchQuery: {[key: string]: string | number} = {
  925. per_page: MAX_AUTOCOMPLETE_RELEASES,
  926. };
  927. if (releaseVersion) {
  928. fetchQuery.query = releaseVersion;
  929. }
  930. if (project) {
  931. fetchQuery.project = project;
  932. }
  933. try {
  934. return await api.requestPromise(url, {
  935. method: 'GET',
  936. query: fetchQuery,
  937. });
  938. } catch (e) {
  939. addErrorMessage(t('Unable to fetch releases'));
  940. Sentry.captureException(e);
  941. }
  942. return [];
  943. };
  944. async generateTagAutocompleteGroup(tagName: string): Promise<AutocompleteGroup> {
  945. const [tagKeys, tagType] = this.getTagKeys(tagName);
  946. const recentSearches = await this.getRecentSearches();
  947. return {
  948. searchItems: tagKeys,
  949. recentSearchItems: recentSearches ?? [],
  950. tagName,
  951. type: tagType,
  952. };
  953. }
  954. generateValueAutocompleteGroup = async (
  955. tagName: string,
  956. query: string
  957. ): Promise<AutocompleteGroup | null> => {
  958. const {prepareQuery, excludeEnvironment} = this.props;
  959. const supportedTags = this.props.supportedTags ?? {};
  960. const preparedQuery =
  961. typeof prepareQuery === 'function' ? prepareQuery(query) : query;
  962. // filter existing items immediately, until API can return
  963. // with actual tag value results
  964. const filteredSearchGroups = !preparedQuery
  965. ? this.state.searchGroups
  966. : this.state.searchGroups.filter(
  967. item => item.value && item.value.indexOf(preparedQuery) !== -1
  968. );
  969. this.setState({
  970. searchTerm: query,
  971. searchGroups: filteredSearchGroups,
  972. });
  973. const tag = supportedTags[tagName];
  974. if (!tag) {
  975. return {
  976. searchItems: [
  977. {
  978. type: ItemType.INVALID_TAG,
  979. desc: tagName,
  980. callback: () =>
  981. window.open(
  982. 'https://docs.sentry.io/product/sentry-basics/search/searchable-properties/'
  983. ),
  984. },
  985. ],
  986. recentSearchItems: [],
  987. tagName,
  988. type: ItemType.INVALID_TAG,
  989. };
  990. }
  991. // Ignore the environment tag if the feature is active and
  992. // excludeEnvironment = true
  993. if (excludeEnvironment && tagName === 'environment') {
  994. return null;
  995. }
  996. const fetchTagValuesFn =
  997. tag.key === 'firstRelease'
  998. ? this.getReleases
  999. : tag.predefined
  1000. ? this.getPredefinedTagValues
  1001. : this.getTagValues;
  1002. const [tagValues, recentSearches] = await Promise.all([
  1003. fetchTagValuesFn(tag, preparedQuery),
  1004. this.getRecentSearches(),
  1005. ]);
  1006. return {
  1007. searchItems: tagValues ?? [],
  1008. recentSearchItems: recentSearches ?? [],
  1009. tagName: tag.key,
  1010. type: ItemType.TAG_VALUE,
  1011. };
  1012. };
  1013. showDefaultSearches = async () => {
  1014. const {query} = this.state;
  1015. const [defaultSearchItems, defaultRecentItems] = this.props.defaultSearchItems!;
  1016. // Always clear searchTerm on showing default state.
  1017. this.setState({searchTerm: ''});
  1018. if (!defaultSearchItems.length) {
  1019. // Update searchTerm, otherwise <SearchDropdown> will have wrong state
  1020. // (e.g. if you delete a query, the last letter will be highlighted if `searchTerm`
  1021. // does not get updated)
  1022. const [tagKeys, tagType] = this.getTagKeys('');
  1023. const recentSearches = await this.getRecentSearches();
  1024. if (this.state.query === query) {
  1025. this.updateAutoCompleteState(tagKeys, recentSearches ?? [], '', tagType);
  1026. }
  1027. return;
  1028. }
  1029. this.updateAutoCompleteState(
  1030. defaultSearchItems,
  1031. defaultRecentItems,
  1032. '',
  1033. ItemType.DEFAULT
  1034. );
  1035. return;
  1036. };
  1037. updateAutoCompleteFromAst = async () => {
  1038. const cursor = this.cursorPosition;
  1039. const cursorToken = this.cursorToken;
  1040. if (!cursorToken) {
  1041. this.showDefaultSearches();
  1042. return;
  1043. }
  1044. if (cursorToken.type === Token.Filter) {
  1045. const tagName = getKeyName(cursorToken.key, {aggregateWithArgs: true});
  1046. // check if we are on the tag, value, or operator
  1047. if (isWithinToken(cursorToken.value, cursor)) {
  1048. const node = cursorToken.value;
  1049. const cursorValue = this.cursorValue;
  1050. let searchText = cursorValue?.text ?? node.text;
  1051. if (searchText === '[]' || cursorValue === null) {
  1052. searchText = '';
  1053. }
  1054. const valueGroup = await this.generateValueAutocompleteGroup(tagName, searchText);
  1055. const autocompleteGroups = valueGroup ? [valueGroup] : [];
  1056. // show operator group if at beginning of value
  1057. if (cursor === node.location.start.offset) {
  1058. const opGroup = generateOpAutocompleteGroup(getValidOps(cursorToken), tagName);
  1059. if (valueGroup?.type !== ItemType.INVALID_TAG) {
  1060. autocompleteGroups.unshift(opGroup);
  1061. }
  1062. }
  1063. if (cursor === this.cursorPosition) {
  1064. this.updateAutoCompleteStateMultiHeader(autocompleteGroups);
  1065. }
  1066. return;
  1067. }
  1068. if (isWithinToken(cursorToken.key, cursor)) {
  1069. const node = cursorToken.key;
  1070. const autocompleteGroups = [await this.generateTagAutocompleteGroup(tagName)];
  1071. // show operator group if at end of key
  1072. if (cursor === node.location.end.offset) {
  1073. const opGroup = generateOpAutocompleteGroup(getValidOps(cursorToken), tagName);
  1074. autocompleteGroups.unshift(opGroup);
  1075. }
  1076. if (cursor === this.cursorPosition) {
  1077. this.setState({searchTerm: tagName});
  1078. this.updateAutoCompleteStateMultiHeader(autocompleteGroups);
  1079. }
  1080. return;
  1081. }
  1082. // show operator autocomplete group
  1083. const opGroup = generateOpAutocompleteGroup(getValidOps(cursorToken), tagName);
  1084. this.updateAutoCompleteStateMultiHeader([opGroup]);
  1085. return;
  1086. }
  1087. if (cursorToken.type === Token.FreeText) {
  1088. const lastToken = cursorToken.text.trim().split(' ').pop() ?? '';
  1089. const keyText = lastToken.replace(new RegExp(`^${NEGATION_OPERATOR}`), '');
  1090. const autocompleteGroups = [await this.generateTagAutocompleteGroup(keyText)];
  1091. if (cursor === this.cursorPosition) {
  1092. this.setState({searchTerm: keyText});
  1093. this.updateAutoCompleteStateMultiHeader(autocompleteGroups);
  1094. }
  1095. return;
  1096. }
  1097. };
  1098. updateAutoCompleteItems = () => {
  1099. window.clearTimeout(this.blurTimeout);
  1100. this.blurTimeout = undefined;
  1101. this.updateAutoCompleteFromAst();
  1102. };
  1103. /**
  1104. * Updates autocomplete dropdown items and autocomplete index state
  1105. *
  1106. * @param searchItems List of search item objects with keys: title, desc, value
  1107. * @param recentSearchItems List of recent search items, same format as searchItem
  1108. * @param tagName The current tag name in scope
  1109. * @param type Defines the type/state of the dropdown menu items
  1110. */
  1111. updateAutoCompleteState(
  1112. searchItems: SearchItem[],
  1113. recentSearchItems: SearchItem[],
  1114. tagName: string,
  1115. type: ItemType
  1116. ) {
  1117. const {hasRecentSearches, maxSearchItems, maxQueryLength} = this.props;
  1118. const {query} = this.state;
  1119. const queryCharsLeft =
  1120. maxQueryLength && query ? maxQueryLength - query.length : undefined;
  1121. const searchGroups = createSearchGroups(
  1122. searchItems,
  1123. hasRecentSearches ? recentSearchItems : undefined,
  1124. tagName,
  1125. type,
  1126. maxSearchItems,
  1127. queryCharsLeft,
  1128. true
  1129. );
  1130. this.setState(searchGroups);
  1131. }
  1132. /**
  1133. * Updates autocomplete dropdown items and autocomplete index state
  1134. *
  1135. * @param groups Groups that will be used to populate the autocomplete dropdown
  1136. */
  1137. updateAutoCompleteStateMultiHeader = (groups: AutocompleteGroup[]) => {
  1138. const {hasRecentSearches, maxSearchItems, maxQueryLength} = this.props;
  1139. const {query} = this.state;
  1140. const queryCharsLeft =
  1141. maxQueryLength && query ? maxQueryLength - query.length : undefined;
  1142. const searchGroups = groups
  1143. .map(({searchItems, recentSearchItems, tagName, type}) =>
  1144. createSearchGroups(
  1145. searchItems,
  1146. hasRecentSearches ? recentSearchItems : undefined,
  1147. tagName,
  1148. type,
  1149. maxSearchItems,
  1150. queryCharsLeft,
  1151. false
  1152. )
  1153. )
  1154. .reduce(
  1155. (acc, item) => ({
  1156. searchGroups: [...acc.searchGroups, ...item.searchGroups],
  1157. flatSearchItems: [...acc.flatSearchItems, ...item.flatSearchItems],
  1158. activeSearchItem: -1,
  1159. }),
  1160. {
  1161. searchGroups: [] as SearchGroup[],
  1162. flatSearchItems: [] as SearchItem[],
  1163. activeSearchItem: -1,
  1164. }
  1165. );
  1166. this.setState(searchGroups);
  1167. };
  1168. updateQuery = (newQuery: string, cursorPosition?: number) =>
  1169. this.setState(makeQueryState(newQuery), () => {
  1170. // setting a new input value will lose focus; restore it
  1171. if (this.searchInput.current) {
  1172. this.searchInput.current.focus();
  1173. if (cursorPosition) {
  1174. this.searchInput.current.selectionStart = cursorPosition;
  1175. this.searchInput.current.selectionEnd = cursorPosition;
  1176. }
  1177. }
  1178. // then update the autocomplete box with new items
  1179. this.updateAutoCompleteItems();
  1180. this.props.onChange?.(newQuery, new MouseEvent('click') as any);
  1181. });
  1182. onAutoCompleteFromAst = (replaceText: string, item: SearchItem) => {
  1183. const cursor = this.cursorPosition;
  1184. const {query} = this.state;
  1185. const cursorToken = this.cursorToken;
  1186. if (!cursorToken) {
  1187. this.updateQuery(`${query}${replaceText}`);
  1188. return;
  1189. }
  1190. // the start and end of what to replace
  1191. let clauseStart: null | number = null;
  1192. let clauseEnd: null | number = null;
  1193. // the new text that will exist between clauseStart and clauseEnd
  1194. let replaceToken = replaceText;
  1195. if (cursorToken.type === Token.Filter) {
  1196. if (item.type === ItemType.TAG_OPERATOR) {
  1197. trackAdvancedAnalyticsEvent('search.operator_autocompleted', {
  1198. organization: this.props.organization,
  1199. query: removeSpace(query),
  1200. search_operator: replaceText,
  1201. search_type: this.props.savedSearchType === 0 ? 'issues' : 'events',
  1202. });
  1203. const valueLocation = cursorToken.value.location;
  1204. clauseStart = cursorToken.location.start.offset;
  1205. clauseEnd = valueLocation.start.offset;
  1206. if (replaceText === '!:') {
  1207. replaceToken = `!${cursorToken.key.text}:`;
  1208. } else {
  1209. replaceToken = `${cursorToken.key.text}${replaceText}`;
  1210. }
  1211. } else if (isWithinToken(cursorToken.value, cursor)) {
  1212. const valueToken = this.cursorValue ?? cursorToken.value;
  1213. const location = valueToken.location;
  1214. if (cursorToken.filter === FilterType.TextIn) {
  1215. // Current value can be null when adding a 2nd value
  1216. // ▼ cursor
  1217. // key:[value1, ]
  1218. const currentValueNull = this.cursorValue === null;
  1219. clauseStart = currentValueNull
  1220. ? this.cursorPosition
  1221. : valueToken.location.start.offset;
  1222. clauseEnd = currentValueNull
  1223. ? this.cursorPosition
  1224. : valueToken.location.end.offset;
  1225. } else {
  1226. const keyLocation = cursorToken.key.location;
  1227. clauseStart = keyLocation.end.offset + 1;
  1228. clauseEnd = location.end.offset + 1;
  1229. // The user tag often contains : within its value and we need to quote it.
  1230. if (getKeyName(cursorToken.key) === 'user') {
  1231. replaceToken = `"${replaceText.trim()}"`;
  1232. }
  1233. // handle using autocomplete with key:[]
  1234. if (valueToken.text === '[]') {
  1235. clauseStart += 1;
  1236. clauseEnd -= 2;
  1237. } else {
  1238. replaceToken += ' ';
  1239. }
  1240. }
  1241. } else if (isWithinToken(cursorToken.key, cursor)) {
  1242. const location = cursorToken.key.location;
  1243. clauseStart = location.start.offset;
  1244. // If the token is a key, then trim off the end to avoid duplicate ':'
  1245. clauseEnd = location.end.offset + 1;
  1246. }
  1247. }
  1248. if (cursorToken.type === Token.FreeText) {
  1249. const startPos = cursorToken.location.start.offset;
  1250. clauseStart = cursorToken.text.startsWith(NEGATION_OPERATOR)
  1251. ? startPos + 1
  1252. : startPos;
  1253. clauseEnd = cursorToken.location.end.offset;
  1254. }
  1255. if (clauseStart !== null && clauseEnd !== null) {
  1256. const beforeClause = query.substring(0, clauseStart);
  1257. const endClause = query.substring(clauseEnd);
  1258. const newQuery = `${beforeClause}${replaceToken}${endClause}`;
  1259. this.updateQuery(newQuery, beforeClause.length + replaceToken.length);
  1260. }
  1261. };
  1262. onAutoComplete = (replaceText: string, item: SearchItem) => {
  1263. if (item.type === ItemType.RECENT_SEARCH) {
  1264. trackAdvancedAnalyticsEvent('search.searched', {
  1265. organization: this.props.organization,
  1266. query: replaceText,
  1267. search_type: this.props.savedSearchType === 0 ? 'issues' : 'events',
  1268. search_source: 'recent_search',
  1269. });
  1270. this.setState(makeQueryState(replaceText), () => {
  1271. // Propagate onSearch and save to recent searches
  1272. this.doSearch();
  1273. });
  1274. return;
  1275. }
  1276. this.onAutoCompleteFromAst(replaceText, item);
  1277. };
  1278. get showSearchDropdown(): boolean {
  1279. return this.state.loading || this.state.searchGroups.length > 0;
  1280. }
  1281. render() {
  1282. const {
  1283. api,
  1284. className,
  1285. savedSearchType,
  1286. dropdownClassName,
  1287. actionBarItems,
  1288. organization,
  1289. placeholder,
  1290. disabled,
  1291. useFormWrapper,
  1292. inlineLabel,
  1293. maxQueryLength,
  1294. maxMenuHeight,
  1295. } = this.props;
  1296. const {
  1297. query,
  1298. parsedQuery,
  1299. searchGroups,
  1300. searchTerm,
  1301. inputHasFocus,
  1302. numActionsVisible,
  1303. loading,
  1304. } = this.state;
  1305. const input = (
  1306. <SearchInput
  1307. type="text"
  1308. placeholder={placeholder}
  1309. id="smart-search-input"
  1310. data-test-id="smart-search-input"
  1311. name="query"
  1312. ref={this.searchInput}
  1313. autoComplete="off"
  1314. value={query}
  1315. onFocus={this.onQueryFocus}
  1316. onBlur={this.onQueryBlur}
  1317. onKeyUp={this.onKeyUp}
  1318. onKeyDown={this.onKeyDown}
  1319. onChange={this.onQueryChange}
  1320. onClick={this.onInputClick}
  1321. onPaste={this.onPaste}
  1322. disabled={disabled}
  1323. maxLength={maxQueryLength}
  1324. spellCheck={false}
  1325. />
  1326. );
  1327. // Segment actions into visible and overflowed groups
  1328. const actionItems = actionBarItems ?? [];
  1329. const actionProps = {
  1330. api,
  1331. organization,
  1332. query,
  1333. savedSearchType,
  1334. };
  1335. const visibleActions = actionItems
  1336. .slice(0, numActionsVisible)
  1337. .map(({key, Action}) => <Action key={key} {...actionProps} />);
  1338. const overflowedActions = actionItems
  1339. .slice(numActionsVisible)
  1340. .map(({key, Action}) => <Action key={key} {...actionProps} menuItemVariant />);
  1341. const cursor = this.cursorPosition;
  1342. const visibleShortcuts = shortcuts.filter(
  1343. shortcut =>
  1344. shortcut.hotkeys &&
  1345. shortcut.canRunShortcut(this.cursorToken, this.filterTokens.length)
  1346. );
  1347. return (
  1348. <Container
  1349. ref={this.containerRef}
  1350. className={className}
  1351. inputHasFocus={inputHasFocus}
  1352. >
  1353. <SearchHotkeysListener
  1354. visibleShortcuts={visibleShortcuts}
  1355. runShortcut={this.runShortcut}
  1356. />
  1357. <SearchLabel htmlFor="smart-search-input" aria-label={t('Search events')}>
  1358. <IconSearch />
  1359. {inlineLabel}
  1360. </SearchLabel>
  1361. <InputWrapper>
  1362. <Highlight>
  1363. {parsedQuery !== null ? (
  1364. <HighlightQuery
  1365. parsedQuery={parsedQuery}
  1366. cursorPosition={cursor === -1 ? undefined : cursor}
  1367. />
  1368. ) : (
  1369. query
  1370. )}
  1371. </Highlight>
  1372. {useFormWrapper ? <form onSubmit={this.onSubmit}>{input}</form> : input}
  1373. </InputWrapper>
  1374. <ActionsBar gap={0.5}>
  1375. {query !== '' && (
  1376. <ActionButton
  1377. onClick={this.clearSearch}
  1378. icon={<IconClose size="xs" />}
  1379. title={t('Clear search')}
  1380. aria-label={t('Clear search')}
  1381. />
  1382. )}
  1383. {visibleActions}
  1384. {overflowedActions.length > 0 && (
  1385. <DropdownLink
  1386. anchorRight
  1387. caret={false}
  1388. title={
  1389. <ActionButton
  1390. aria-label={t('Show more')}
  1391. icon={<VerticalEllipsisIcon size="xs" />}
  1392. />
  1393. }
  1394. >
  1395. {overflowedActions}
  1396. </DropdownLink>
  1397. )}
  1398. </ActionsBar>
  1399. {this.state.showDropdown && (
  1400. <SearchDropdown
  1401. css={{display: inputHasFocus ? 'block' : 'none'}}
  1402. className={dropdownClassName}
  1403. items={searchGroups}
  1404. onClick={this.onAutoComplete}
  1405. loading={loading}
  1406. searchSubstring={searchTerm}
  1407. runShortcut={this.runShortcut}
  1408. visibleShortcuts={visibleShortcuts}
  1409. maxMenuHeight={maxMenuHeight}
  1410. />
  1411. )}
  1412. </Container>
  1413. );
  1414. }
  1415. }
  1416. type ContainerState = {
  1417. members: ReturnType<typeof MemberListStore.getAll>;
  1418. };
  1419. class SmartSearchBarContainer extends Component<Props, ContainerState> {
  1420. state: ContainerState = {
  1421. members: MemberListStore.getAll(),
  1422. };
  1423. componentWillUnmount() {
  1424. this.unsubscribe();
  1425. }
  1426. unsubscribe = MemberListStore.listen(
  1427. (members: ContainerState['members']) => this.setState({members}),
  1428. undefined
  1429. );
  1430. render() {
  1431. // SmartSearchBar doesn't use members, but we forward it to cause a re-render.
  1432. return <SmartSearchBar {...this.props} members={this.state.members} />;
  1433. }
  1434. }
  1435. export default withApi(withRouter(withOrganization(SmartSearchBarContainer)));
  1436. export {SmartSearchBar, Props as SmartSearchBarProps};
  1437. const Container = styled('div')<{inputHasFocus: boolean}>`
  1438. border: 1px solid ${p => p.theme.border};
  1439. box-shadow: inset ${p => p.theme.dropShadowLight};
  1440. background: ${p => p.theme.background};
  1441. padding: 7px ${space(1)};
  1442. position: relative;
  1443. display: grid;
  1444. grid-template-columns: max-content 1fr max-content;
  1445. gap: ${space(1)};
  1446. align-items: start;
  1447. border-radius: ${p => p.theme.borderRadius};
  1448. .show-sidebar & {
  1449. background: ${p => p.theme.backgroundSecondary};
  1450. }
  1451. ${p =>
  1452. p.inputHasFocus &&
  1453. `
  1454. border-color: ${p.theme.focusBorder};
  1455. box-shadow: 0 0 0 1px ${p.theme.focusBorder};
  1456. `}
  1457. `;
  1458. const SearchLabel = styled('label')`
  1459. display: flex;
  1460. padding: ${space(0.5)} 0;
  1461. margin: 0;
  1462. color: ${p => p.theme.gray300};
  1463. `;
  1464. const InputWrapper = styled('div')`
  1465. position: relative;
  1466. `;
  1467. const Highlight = styled('div')`
  1468. position: absolute;
  1469. top: 0;
  1470. left: 0;
  1471. right: 0;
  1472. bottom: 0;
  1473. user-select: none;
  1474. white-space: pre-wrap;
  1475. word-break: break-word;
  1476. line-height: 25px;
  1477. font-size: ${p => p.theme.fontSizeSmall};
  1478. font-family: ${p => p.theme.text.familyMono};
  1479. `;
  1480. const SearchInput = styled(
  1481. getDynamicComponent<typeof TextareaAutosize>({
  1482. value: TextareaAutosize,
  1483. fixed: 'textarea',
  1484. }),
  1485. {
  1486. shouldForwardProp: prop => typeof prop === 'string' && isPropValid(prop),
  1487. }
  1488. )`
  1489. position: relative;
  1490. display: flex;
  1491. resize: none;
  1492. outline: none;
  1493. border: 0;
  1494. width: 100%;
  1495. padding: 0;
  1496. line-height: 25px;
  1497. margin-bottom: -1px;
  1498. background: transparent;
  1499. font-size: ${p => p.theme.fontSizeSmall};
  1500. font-family: ${p => p.theme.text.familyMono};
  1501. caret-color: ${p => p.theme.subText};
  1502. color: transparent;
  1503. &::selection {
  1504. background: rgba(0, 0, 0, 0.2);
  1505. }
  1506. &::placeholder {
  1507. color: ${p => p.theme.formPlaceholder};
  1508. }
  1509. [disabled] {
  1510. color: ${p => p.theme.disabled};
  1511. }
  1512. `;
  1513. const ActionsBar = styled(ButtonBar)`
  1514. height: ${space(2)};
  1515. margin: ${space(0.5)} 0;
  1516. `;
  1517. const VerticalEllipsisIcon = styled(IconEllipsis)`
  1518. transform: rotate(90deg);
  1519. `;