index.tsx 62 KB

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