index.tsx 57 KB

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