index.tsx 51 KB

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