searchDropdown.tsx 17 KB

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