searchDropdown.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. import {Fragment, PureComponent} from 'react';
  2. import styled from '@emotion/styled';
  3. import color from 'color';
  4. import LoadingIndicator from 'sentry/components/loadingIndicator';
  5. import {parseSearch} from 'sentry/components/searchSyntax/parser';
  6. import HighlightQuery from 'sentry/components/searchSyntax/renderer';
  7. import {IconOpen} from 'sentry/icons';
  8. import {t, tct} from 'sentry/locale';
  9. import space from 'sentry/styles/space';
  10. import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
  11. import Button from '../button';
  12. import HotkeysLabel from '../hotkeysLabel';
  13. import Tag from '../tag';
  14. import {ItemType, SearchGroup, SearchItem, Shortcut} from './types';
  15. const getDropdownItemKey = (item: SearchItem) => item.value || item.desc || item.title;
  16. type Props = {
  17. items: SearchGroup[];
  18. loading: boolean;
  19. onClick: (value: string, item: SearchItem) => void;
  20. searchSubstring: string;
  21. className?: string;
  22. maxMenuHeight?: number;
  23. onIconClick?: (value: string) => void;
  24. runShortcut?: (shortcut: Shortcut) => void;
  25. visibleShortcuts?: Shortcut[];
  26. };
  27. class SearchDropdown extends PureComponent<Props> {
  28. static defaultProps = {
  29. searchSubstring: '',
  30. onClick: function () {},
  31. };
  32. render() {
  33. const {
  34. className,
  35. loading,
  36. items,
  37. runShortcut,
  38. visibleShortcuts,
  39. maxMenuHeight,
  40. searchSubstring,
  41. onClick,
  42. onIconClick,
  43. } = this.props;
  44. return (
  45. <StyledSearchDropdown className={className}>
  46. {loading ? (
  47. <LoadingWrapper key="loading" data-test-id="search-autocomplete-loading">
  48. <LoadingIndicator mini />
  49. </LoadingWrapper>
  50. ) : (
  51. <SearchItemsList maxMenuHeight={maxMenuHeight}>
  52. {items.map(item => {
  53. const isEmpty = item.children && !item.children.length;
  54. // Hide header if `item.children` is defined, an array, and is empty
  55. return (
  56. <Fragment key={item.title}>
  57. {item.type === 'header' && <HeaderItem group={item} />}
  58. {item.children &&
  59. item.children.map(child => (
  60. <DropdownItem
  61. key={getDropdownItemKey(child)}
  62. item={child}
  63. searchSubstring={searchSubstring}
  64. onClick={onClick}
  65. onIconClick={onIconClick}
  66. />
  67. ))}
  68. {isEmpty && <Info>{t('No items found')}</Info>}
  69. </Fragment>
  70. );
  71. })}
  72. </SearchItemsList>
  73. )}
  74. <DropdownFooter>
  75. <ShortcutsRow>
  76. {runShortcut &&
  77. visibleShortcuts?.map(shortcut => {
  78. return (
  79. <ShortcutButtonContainer
  80. key={shortcut.text}
  81. onClick={() => runShortcut(shortcut)}
  82. >
  83. <HotkeyGlyphWrapper>
  84. <HotkeysLabel
  85. value={
  86. shortcut.hotkeys?.display ?? shortcut.hotkeys?.actual ?? []
  87. }
  88. />
  89. </HotkeyGlyphWrapper>
  90. <IconWrapper>{shortcut.icon}</IconWrapper>
  91. <HotkeyTitle>{shortcut.text}</HotkeyTitle>
  92. </ShortcutButtonContainer>
  93. );
  94. })}
  95. </ShortcutsRow>
  96. <Button size="xs" href="https://docs.sentry.io/product/sentry-basics/search/">
  97. Read the docs
  98. </Button>
  99. </DropdownFooter>
  100. </StyledSearchDropdown>
  101. );
  102. }
  103. }
  104. export default SearchDropdown;
  105. type HeaderItemProps = {
  106. group: SearchGroup;
  107. };
  108. const HeaderItem = ({group}: HeaderItemProps) => {
  109. return (
  110. <SearchDropdownGroup key={group.title}>
  111. <SearchDropdownGroupTitle>
  112. {group.icon}
  113. {group.title && group.title}
  114. {group.desc && <span>{group.desc}</span>}
  115. </SearchDropdownGroupTitle>
  116. </SearchDropdownGroup>
  117. );
  118. };
  119. type HighlightedRestOfWordsProps = {
  120. combinedRestWords: string;
  121. firstWord: string;
  122. searchSubstring: string;
  123. hasSplit?: boolean;
  124. isFirstWordHidden?: boolean;
  125. };
  126. const HighlightedRestOfWords = ({
  127. combinedRestWords,
  128. searchSubstring,
  129. firstWord,
  130. isFirstWordHidden,
  131. hasSplit,
  132. }: HighlightedRestOfWordsProps) => {
  133. const remainingSubstr =
  134. searchSubstring.indexOf(firstWord) === -1
  135. ? searchSubstring
  136. : searchSubstring.slice(firstWord.length + 1);
  137. const descIdx = combinedRestWords.indexOf(remainingSubstr);
  138. if (descIdx > -1) {
  139. return (
  140. <RestOfWordsContainer isFirstWordHidden={isFirstWordHidden} hasSplit={hasSplit}>
  141. .{combinedRestWords.slice(0, descIdx)}
  142. <strong>
  143. {combinedRestWords.slice(descIdx, descIdx + remainingSubstr.length)}
  144. </strong>
  145. {combinedRestWords.slice(descIdx + remainingSubstr.length)}
  146. </RestOfWordsContainer>
  147. );
  148. }
  149. return (
  150. <RestOfWordsContainer isFirstWordHidden={isFirstWordHidden} hasSplit={hasSplit}>
  151. .{combinedRestWords}
  152. </RestOfWordsContainer>
  153. );
  154. };
  155. type ItemTitleProps = {
  156. item: SearchItem;
  157. searchSubstring: string;
  158. isChild?: boolean;
  159. };
  160. const ItemTitle = ({item, searchSubstring, isChild}: ItemTitleProps) => {
  161. if (!item.title) {
  162. return null;
  163. }
  164. const fullWord = item.title;
  165. const words = item.kind !== FieldValueKind.FUNCTION ? fullWord.split('.') : [fullWord];
  166. const [firstWord, ...restWords] = words;
  167. const isFirstWordHidden = isChild;
  168. const combinedRestWords = restWords.length > 0 ? restWords.join('.') : null;
  169. const hasSingleField = item.type === ItemType.LINK;
  170. if (searchSubstring) {
  171. const idx =
  172. restWords.length === 0
  173. ? fullWord.toLowerCase().indexOf(searchSubstring.split('.')[0])
  174. : fullWord.toLowerCase().indexOf(searchSubstring);
  175. // Below is the logic to make the current query bold inside the result.
  176. if (idx !== -1) {
  177. return (
  178. <SearchItemTitleWrapper hasSingleField={hasSingleField}>
  179. {!isFirstWordHidden && (
  180. <FirstWordWrapper>
  181. {firstWord.slice(0, idx)}
  182. <strong>{firstWord.slice(idx, idx + searchSubstring.length)}</strong>
  183. {firstWord.slice(idx + searchSubstring.length)}
  184. </FirstWordWrapper>
  185. )}
  186. {combinedRestWords && (
  187. <HighlightedRestOfWords
  188. firstWord={firstWord}
  189. isFirstWordHidden={isFirstWordHidden}
  190. searchSubstring={searchSubstring}
  191. combinedRestWords={combinedRestWords}
  192. hasSplit={words.length > 1}
  193. />
  194. )}
  195. </SearchItemTitleWrapper>
  196. );
  197. }
  198. }
  199. return (
  200. <SearchItemTitleWrapper>
  201. {!isFirstWordHidden && <FirstWordWrapper>{firstWord}</FirstWordWrapper>}
  202. {combinedRestWords && (
  203. <RestOfWordsContainer
  204. isFirstWordHidden={isFirstWordHidden}
  205. hasSplit={words.length > 1}
  206. >
  207. .{combinedRestWords}
  208. </RestOfWordsContainer>
  209. )}
  210. </SearchItemTitleWrapper>
  211. );
  212. };
  213. type KindTagProps = {kind: FieldValueKind};
  214. const KindTag = ({kind}: KindTagProps) => {
  215. let text, tagType;
  216. switch (kind) {
  217. case FieldValueKind.FUNCTION:
  218. text = 'f(x)';
  219. tagType = 'success';
  220. break;
  221. case FieldValueKind.MEASUREMENT:
  222. text = 'field';
  223. tagType = 'highlight';
  224. break;
  225. case FieldValueKind.BREAKDOWN:
  226. text = 'field';
  227. tagType = 'highlight';
  228. break;
  229. case FieldValueKind.TAG:
  230. text = kind;
  231. tagType = 'warning';
  232. break;
  233. case FieldValueKind.NUMERIC_METRICS:
  234. text = 'f(x)';
  235. tagType = 'success';
  236. break;
  237. case FieldValueKind.FIELD:
  238. default:
  239. text = kind;
  240. }
  241. return <Tag type={tagType}>{text}</Tag>;
  242. };
  243. type DropdownItemProps = {
  244. item: SearchItem;
  245. onClick: (value: string, item: SearchItem) => void;
  246. searchSubstring: string;
  247. isChild?: boolean;
  248. onIconClick?: any;
  249. };
  250. const DropdownItem = ({
  251. item,
  252. isChild,
  253. searchSubstring,
  254. onClick,
  255. onIconClick,
  256. }: DropdownItemProps) => {
  257. const isDisabled = item.value === null;
  258. let children: React.ReactNode;
  259. if (item.type === ItemType.RECENT_SEARCH) {
  260. children = <QueryItem item={item} />;
  261. } else if (item.type === ItemType.INVALID_TAG) {
  262. children = (
  263. <Invalid>
  264. {tct("The field [field] isn't supported here. ", {
  265. field: <strong>{item.desc}</strong>,
  266. })}
  267. {tct('[highlight:See all searchable properties in the docs.]', {
  268. highlight: <Highlight />,
  269. })}
  270. </Invalid>
  271. );
  272. } else if (item.type === ItemType.LINK) {
  273. children = (
  274. <Fragment>
  275. <ItemTitle item={item} isChild={isChild} searchSubstring={searchSubstring} />
  276. {onIconClick && (
  277. <IconOpen
  278. onClick={e => {
  279. // stop propagation so the item-level onClick doesn't get called
  280. e.stopPropagation();
  281. onIconClick(item.value);
  282. }}
  283. />
  284. )}
  285. </Fragment>
  286. );
  287. } else {
  288. children = (
  289. <Fragment>
  290. <ItemTitle item={item} isChild={isChild} searchSubstring={searchSubstring} />
  291. {item.desc && <Value hasDocs={!!item.documentation}>{item.desc}</Value>}
  292. <Documentation>{item.documentation}</Documentation>
  293. <TagWrapper>{item.kind && !isChild && <KindTag kind={item.kind} />}</TagWrapper>
  294. </Fragment>
  295. );
  296. }
  297. return (
  298. <Fragment>
  299. <SearchListItem
  300. className={`${isChild ? 'group-child' : ''} ${item.active ? 'active' : ''}`}
  301. data-test-id="search-autocomplete-item"
  302. onClick={
  303. !isDisabled ? item.callback ?? onClick.bind(this, item.value, item) : undefined
  304. }
  305. ref={element => item.active && element?.scrollIntoView?.({block: 'nearest'})}
  306. isGrouped={isChild}
  307. isDisabled={isDisabled}
  308. >
  309. {children}
  310. </SearchListItem>
  311. {!isChild &&
  312. item.children?.map(child => (
  313. <DropdownItem
  314. key={getDropdownItemKey(child)}
  315. item={child}
  316. onClick={onClick}
  317. searchSubstring={searchSubstring}
  318. isChild
  319. />
  320. ))}
  321. </Fragment>
  322. );
  323. };
  324. type QueryItemProps = {item: SearchItem};
  325. const QueryItem = ({item}: QueryItemProps) => {
  326. if (!item.value) {
  327. return null;
  328. }
  329. const parsedQuery = parseSearch(item.value);
  330. if (!parsedQuery) {
  331. return null;
  332. }
  333. return (
  334. <QueryItemWrapper>
  335. <HighlightQuery parsedQuery={parsedQuery} />
  336. </QueryItemWrapper>
  337. );
  338. };
  339. const StyledSearchDropdown = styled('div')`
  340. /* Container has a border that we need to account for */
  341. position: absolute;
  342. top: 100%;
  343. left: -1px;
  344. right: -1px;
  345. z-index: ${p => p.theme.zIndex.dropdown};
  346. overflow: hidden;
  347. margin-top: ${space(1)};
  348. background: ${p => p.theme.background};
  349. box-shadow: ${p => p.theme.dropShadowHeavy};
  350. border: 1px solid ${p => p.theme.border};
  351. border-radius: ${p => p.theme.borderRadius};
  352. `;
  353. const LoadingWrapper = styled('div')`
  354. display: flex;
  355. justify-content: center;
  356. padding: ${space(1)};
  357. `;
  358. const Info = styled('div')`
  359. display: flex;
  360. padding: ${space(1)} ${space(2)};
  361. font-size: ${p => p.theme.fontSizeLarge};
  362. color: ${p => p.theme.gray300};
  363. &:not(:last-child) {
  364. border-bottom: 1px solid ${p => p.theme.innerBorder};
  365. }
  366. `;
  367. const ListItem = styled('li')`
  368. &:not(:first-child):not(.group-child) {
  369. border-top: 1px solid ${p => p.theme.innerBorder};
  370. }
  371. `;
  372. const SearchDropdownGroup = styled(ListItem)``;
  373. const SearchDropdownGroupTitle = styled('header')`
  374. display: flex;
  375. align-items: center;
  376. background-color: ${p => p.theme.backgroundSecondary};
  377. color: ${p => p.theme.gray300};
  378. font-weight: normal;
  379. font-size: ${p => p.theme.fontSizeMedium};
  380. margin: 0;
  381. padding: ${space(1)} ${space(2)};
  382. & > svg {
  383. margin-right: ${space(1)};
  384. }
  385. `;
  386. const SearchItemsList = styled('ul')<{maxMenuHeight?: number}>`
  387. padding-left: 0;
  388. list-style: none;
  389. margin-bottom: 0;
  390. ${p => {
  391. if (p.maxMenuHeight !== undefined) {
  392. return `
  393. max-height: ${p.maxMenuHeight}px;
  394. overflow-y: scroll;
  395. `;
  396. }
  397. return `
  398. height: auto;
  399. `;
  400. }}
  401. `;
  402. const SearchListItem = styled(ListItem)<{isDisabled?: boolean; isGrouped?: boolean}>`
  403. scroll-margin: 40px 0;
  404. font-size: ${p => p.theme.fontSizeLarge};
  405. padding: 4px ${space(2)};
  406. min-height: ${p => (p.isGrouped ? '30px' : '36px')};
  407. ${p => {
  408. if (!p.isDisabled) {
  409. return `
  410. cursor: pointer;
  411. &:hover,
  412. &.active {
  413. background: ${p.theme.hover};
  414. }
  415. `;
  416. }
  417. return '';
  418. }}
  419. display: flex;
  420. flex-direction: row;
  421. justify-content: space-between;
  422. align-items: center;
  423. width: 100%;
  424. `;
  425. const SearchItemTitleWrapper = styled('div')<{hasSingleField?: boolean}>`
  426. display: flex;
  427. flex-grow: 1;
  428. flex-shrink: 0;
  429. max-width: ${p => (p.hasSingleField ? '75%' : 'min(280px, 50%)')};
  430. color: ${p => p.theme.textColor};
  431. font-weight: normal;
  432. font-size: ${p => p.theme.fontSizeMedium};
  433. margin: 0;
  434. line-height: ${p => p.theme.text.lineHeightHeading};
  435. ${p => p.theme.overflowEllipsis};
  436. `;
  437. const RestOfWordsContainer = styled('span')<{
  438. hasSplit?: boolean;
  439. isFirstWordHidden?: boolean;
  440. }>`
  441. color: ${p => (p.hasSplit ? p.theme.blue400 : p.theme.textColor)};
  442. margin-left: ${p => (p.isFirstWordHidden ? space(1) : '0px')};
  443. `;
  444. const FirstWordWrapper = styled('span')`
  445. font-weight: medium;
  446. `;
  447. const TagWrapper = styled('span')`
  448. width: 5%;
  449. display: flex;
  450. flex-direction: row;
  451. align-items: center;
  452. justify-content: flex-end;
  453. `;
  454. const Documentation = styled('span')`
  455. font-size: ${p => p.theme.fontSizeMedium};
  456. font-family: ${p => p.theme.text.family};
  457. color: ${p => p.theme.gray300};
  458. display: flex;
  459. flex: 2;
  460. padding: 0 ${space(1)};
  461. @media (max-width: ${p => p.theme.breakpoints.small}) {
  462. display: none;
  463. }
  464. `;
  465. const DropdownFooter = styled(`div`)`
  466. width: 100%;
  467. min-height: 45px;
  468. background-color: ${p => p.theme.backgroundSecondary};
  469. border-top: 1px solid ${p => p.theme.innerBorder};
  470. flex-direction: row;
  471. display: flex;
  472. align-items: center;
  473. justify-content: space-between;
  474. padding: ${space(1)};
  475. flex-wrap: wrap;
  476. gap: ${space(1)};
  477. `;
  478. const ShortcutsRow = styled('div')`
  479. flex-direction: row;
  480. display: flex;
  481. align-items: center;
  482. `;
  483. const ShortcutButtonContainer = styled('div')`
  484. display: flex;
  485. flex-direction: row;
  486. align-items: center;
  487. height: auto;
  488. padding: 0 ${space(1.5)};
  489. cursor: pointer;
  490. :hover {
  491. border-radius: ${p => p.theme.borderRadius};
  492. background-color: ${p => color(p.theme.hover).darken(0.02).string()};
  493. }
  494. `;
  495. const HotkeyGlyphWrapper = styled('span')`
  496. color: ${p => p.theme.gray300};
  497. margin-right: ${space(0.5)};
  498. @media (max-width: ${p => p.theme.breakpoints.small}) {
  499. display: none;
  500. }
  501. `;
  502. const IconWrapper = styled('span')`
  503. display: none;
  504. @media (max-width: ${p => p.theme.breakpoints.small}) {
  505. display: flex;
  506. margin-right: ${space(0.5)};
  507. align-items: center;
  508. justify-content: center;
  509. }
  510. `;
  511. const HotkeyTitle = styled(`span`)`
  512. font-size: ${p => p.theme.fontSizeSmall};
  513. `;
  514. const Invalid = styled(`span`)`
  515. font-size: ${p => p.theme.fontSizeSmall};
  516. font-family: ${p => p.theme.text.family};
  517. color: ${p => p.theme.gray400};
  518. display: flex;
  519. flex-direction: row;
  520. flex-wrap: wrap;
  521. span {
  522. white-space: pre;
  523. }
  524. `;
  525. const Highlight = styled(`strong`)`
  526. color: ${p => p.theme.linkColor};
  527. `;
  528. const QueryItemWrapper = styled('span')`
  529. font-size: ${p => p.theme.fontSizeSmall};
  530. width: 100%;
  531. gap: ${space(1)};
  532. display: flex;
  533. white-space: nowrap;
  534. word-break: normal;
  535. font-family: ${p => p.theme.text.familyMono};
  536. `;
  537. const Value = styled('span')<{hasDocs?: boolean}>`
  538. font-family: ${p => p.theme.text.familyMono};
  539. font-size: ${p => p.theme.fontSizeSmall};
  540. max-width: ${p => (p.hasDocs ? '280px' : 'none')};
  541. ${p => p.theme.overflowEllipsis};
  542. `;