searchDropdown.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import Button from 'sentry/components/button';
  4. import ButtonBar from 'sentry/components/buttonBar';
  5. import HotkeysLabel from 'sentry/components/hotkeysLabel';
  6. import LoadingIndicator from 'sentry/components/loadingIndicator';
  7. import {Overlay} from 'sentry/components/overlay';
  8. import {parseSearch, SearchConfig} from 'sentry/components/searchSyntax/parser';
  9. import HighlightQuery from 'sentry/components/searchSyntax/renderer';
  10. import Tag from 'sentry/components/tag';
  11. import {IconOpen} from 'sentry/icons';
  12. import {t, tct} from 'sentry/locale';
  13. import space from 'sentry/styles/space';
  14. import {TagCollection} from 'sentry/types';
  15. import {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
  16. import {FieldKind} from 'sentry/utils/fields';
  17. import {ItemType, SearchGroup, SearchItem, Shortcut} from './types';
  18. import {getSearchConfigFromCustomPerformanceMetrics} from './utils';
  19. const getDropdownItemKey = (item: SearchItem) =>
  20. `${item.value || item.desc || item.title}-${
  21. item.children && item.children.length > 0 ? getDropdownItemKey(item.children[0]) : ''
  22. }`;
  23. type Props = {
  24. items: SearchGroup[];
  25. loading: boolean;
  26. onClick: (value: string, item: SearchItem) => void;
  27. searchSubstring: string;
  28. className?: string;
  29. customPerformanceMetrics?: CustomMeasurementCollection;
  30. maxMenuHeight?: number;
  31. onIconClick?: (value: string) => void;
  32. runShortcut?: (shortcut: Shortcut) => void;
  33. supportedTags?: TagCollection;
  34. visibleShortcuts?: Shortcut[];
  35. };
  36. const SearchDropdown = ({
  37. className,
  38. loading,
  39. items,
  40. runShortcut,
  41. visibleShortcuts,
  42. maxMenuHeight,
  43. onIconClick,
  44. searchSubstring = '',
  45. onClick = () => {},
  46. customPerformanceMetrics,
  47. supportedTags,
  48. }: Props) => (
  49. <SearchDropdownOverlay className={className} data-test-id="smart-search-dropdown">
  50. {loading ? (
  51. <LoadingWrapper key="loading" data-test-id="search-autocomplete-loading">
  52. <LoadingIndicator mini />
  53. </LoadingWrapper>
  54. ) : (
  55. <SearchItemsList maxMenuHeight={maxMenuHeight}>
  56. {items.map(item => {
  57. const isEmpty = item.children && !item.children.length;
  58. // Hide header if `item.children` is defined, an array, and is empty
  59. return (
  60. <Fragment key={item.title}>
  61. {item.type === 'header' && <HeaderItem group={item} />}
  62. {item.children &&
  63. item.children.map(child => (
  64. <DropdownItem
  65. key={getDropdownItemKey(child)}
  66. item={child}
  67. searchSubstring={searchSubstring}
  68. onClick={onClick}
  69. onIconClick={onIconClick}
  70. additionalSearchConfig={{
  71. ...getSearchConfigFromCustomPerformanceMetrics(
  72. customPerformanceMetrics
  73. ),
  74. supportedTags,
  75. }}
  76. />
  77. ))}
  78. {isEmpty && <Info>{t('No items found')}</Info>}
  79. </Fragment>
  80. );
  81. })}
  82. </SearchItemsList>
  83. )}
  84. <DropdownFooter>
  85. <ButtonBar gap={1}>
  86. {runShortcut &&
  87. visibleShortcuts?.map(shortcut => (
  88. <Button
  89. borderless
  90. size="xs"
  91. key={shortcut.text}
  92. onClick={() => runShortcut(shortcut)}
  93. >
  94. <HotkeyGlyphWrapper>
  95. <HotkeysLabel
  96. value={shortcut.hotkeys?.display ?? shortcut.hotkeys?.actual ?? []}
  97. />
  98. </HotkeyGlyphWrapper>
  99. <IconWrapper>{shortcut.icon}</IconWrapper>
  100. {shortcut.text}
  101. </Button>
  102. ))}
  103. </ButtonBar>
  104. <Button
  105. size="xs"
  106. href="https://docs.sentry.io/product/sentry-basics/search/"
  107. external
  108. >
  109. Read the docs
  110. </Button>
  111. </DropdownFooter>
  112. </SearchDropdownOverlay>
  113. );
  114. export default SearchDropdown;
  115. type HeaderItemProps = {
  116. group: SearchGroup;
  117. };
  118. const HeaderItem = ({group}: HeaderItemProps) => (
  119. <SearchDropdownGroup key={group.title}>
  120. <SearchDropdownGroupTitle>
  121. {group.icon}
  122. {group.title && group.title}
  123. {group.desc && <span>{group.desc}</span>}
  124. </SearchDropdownGroupTitle>
  125. </SearchDropdownGroup>
  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 = {
  222. kind: FieldKind;
  223. deprecated?: boolean;
  224. };
  225. const KindTag = ({kind, deprecated}: KindTagProps) => {
  226. if (deprecated) {
  227. return <Tag type="error">deprecated</Tag>;
  228. }
  229. switch (kind) {
  230. case FieldKind.FUNCTION:
  231. case FieldKind.NUMERIC_METRICS:
  232. return <Tag type="success">f(x)</Tag>;
  233. case FieldKind.MEASUREMENT:
  234. case FieldKind.BREAKDOWN:
  235. return <Tag type="highlight">field</Tag>;
  236. case FieldKind.TAG:
  237. return <Tag type="warning">{kind}</Tag>;
  238. default:
  239. return <Tag>{kind}</Tag>;
  240. }
  241. };
  242. type DropdownItemProps = {
  243. item: SearchItem;
  244. onClick: (value: string, item: SearchItem) => void;
  245. searchSubstring: string;
  246. additionalSearchConfig?: Partial<SearchConfig>;
  247. isChild?: boolean;
  248. onIconClick?: any;
  249. };
  250. const DropdownItem = ({
  251. item,
  252. isChild,
  253. searchSubstring,
  254. onClick,
  255. onIconClick,
  256. additionalSearchConfig,
  257. }: DropdownItemProps) => {
  258. const isDisabled = item.value === null;
  259. let children: React.ReactNode;
  260. if (item.type === ItemType.RECENT_SEARCH) {
  261. children = <QueryItem item={item} additionalSearchConfig={additionalSearchConfig} />;
  262. } else if (item.type === ItemType.INVALID_TAG) {
  263. children = (
  264. <Invalid>
  265. {tct("The field [field] isn't supported here. ", {
  266. field: <strong>{item.desc}</strong>,
  267. })}
  268. {tct('[highlight:See all searchable properties in the docs.]', {
  269. highlight: <Highlight />,
  270. })}
  271. </Invalid>
  272. );
  273. } else if (item.type === ItemType.LINK) {
  274. children = (
  275. <Fragment>
  276. <ItemTitle item={item} isChild={isChild} searchSubstring={searchSubstring} />
  277. {onIconClick && (
  278. <IconOpen
  279. onClick={e => {
  280. // stop propagation so the item-level onClick doesn't get called
  281. e.stopPropagation();
  282. onIconClick(item.value);
  283. }}
  284. />
  285. )}
  286. </Fragment>
  287. );
  288. } else {
  289. children = (
  290. <Fragment>
  291. <ItemTitle item={item} isChild={isChild} searchSubstring={searchSubstring} />
  292. {item.desc && <Value hasDocs={!!item.documentation}>{item.desc}</Value>}
  293. <DropdownDocumentation
  294. documentation={item.documentation}
  295. searchSubstring={searchSubstring}
  296. />
  297. <TagWrapper>
  298. {item.kind && !isChild && (
  299. <KindTag kind={item.kind} deprecated={item.deprecated} />
  300. )}
  301. </TagWrapper>
  302. </Fragment>
  303. );
  304. }
  305. return (
  306. <Fragment>
  307. <SearchListItem
  308. role="option"
  309. className={`${isChild ? 'group-child' : ''} ${item.active ? 'active' : ''}`}
  310. data-test-id="search-autocomplete-item"
  311. onClick={
  312. !isDisabled ? item.callback ?? onClick.bind(this, item.value, item) : undefined
  313. }
  314. ref={element => item.active && element?.scrollIntoView?.({block: 'nearest'})}
  315. isGrouped={isChild}
  316. isDisabled={isDisabled}
  317. >
  318. {children}
  319. </SearchListItem>
  320. {!isChild &&
  321. item.children?.map(child => (
  322. <DropdownItem
  323. key={getDropdownItemKey(child)}
  324. item={child}
  325. onClick={onClick}
  326. searchSubstring={searchSubstring}
  327. isChild
  328. additionalSearchConfig={additionalSearchConfig}
  329. />
  330. ))}
  331. </Fragment>
  332. );
  333. };
  334. type DropdownDocumentationProps = {
  335. searchSubstring: string;
  336. documentation?: React.ReactNode;
  337. };
  338. const DropdownDocumentation = ({
  339. documentation,
  340. searchSubstring,
  341. }: DropdownDocumentationProps) => {
  342. if (documentation && typeof documentation === 'string') {
  343. const startIndex =
  344. documentation.toLocaleLowerCase().indexOf(searchSubstring.toLocaleLowerCase()) ??
  345. -1;
  346. if (startIndex !== -1) {
  347. const endIndex = startIndex + searchSubstring.length;
  348. return (
  349. <Documentation>
  350. {documentation.slice(0, startIndex)}
  351. <strong>{documentation.slice(startIndex, endIndex)}</strong>
  352. {documentation.slice(endIndex)}
  353. </Documentation>
  354. );
  355. }
  356. }
  357. return <Documentation>{documentation}</Documentation>;
  358. };
  359. type QueryItemProps = {
  360. item: SearchItem;
  361. additionalSearchConfig?: Partial<SearchConfig>;
  362. };
  363. const QueryItem = ({item, additionalSearchConfig}: QueryItemProps) => {
  364. if (!item.value) {
  365. return null;
  366. }
  367. const parsedQuery = parseSearch(item.value, additionalSearchConfig);
  368. if (!parsedQuery) {
  369. return null;
  370. }
  371. return (
  372. <QueryItemWrapper>
  373. <HighlightQuery parsedQuery={parsedQuery} />
  374. </QueryItemWrapper>
  375. );
  376. };
  377. const SearchDropdownOverlay = styled(Overlay)`
  378. position: absolute;
  379. top: 100%;
  380. left: -1px;
  381. right: -1px;
  382. overflow: hidden;
  383. margin-top: ${space(1)};
  384. `;
  385. const LoadingWrapper = styled('div')`
  386. display: flex;
  387. justify-content: center;
  388. padding: ${space(1)};
  389. `;
  390. const Info = styled('div')`
  391. display: flex;
  392. padding: ${space(1)} ${space(2)};
  393. font-size: ${p => p.theme.fontSizeLarge};
  394. color: ${p => p.theme.gray300};
  395. &:not(:last-child) {
  396. border-bottom: 1px solid ${p => p.theme.innerBorder};
  397. }
  398. `;
  399. const ListItem = styled('li')`
  400. &:not(:first-child):not(.group-child) {
  401. border-top: 1px solid ${p => p.theme.innerBorder};
  402. }
  403. `;
  404. const SearchDropdownGroup = styled(ListItem)``;
  405. const SearchDropdownGroupTitle = styled('header')`
  406. display: flex;
  407. align-items: center;
  408. background-color: ${p => p.theme.backgroundSecondary};
  409. color: ${p => p.theme.gray300};
  410. font-weight: normal;
  411. font-size: ${p => p.theme.fontSizeMedium};
  412. margin: 0;
  413. padding: ${space(1)} ${space(2)};
  414. & > svg {
  415. margin-right: ${space(1)};
  416. }
  417. `;
  418. const SearchItemsList = styled('ul')<{maxMenuHeight?: number}>`
  419. padding-left: 0;
  420. list-style: none;
  421. margin-bottom: 0;
  422. ${p => {
  423. if (p.maxMenuHeight !== undefined) {
  424. return `
  425. max-height: ${p.maxMenuHeight}px;
  426. overflow-y: scroll;
  427. `;
  428. }
  429. return `
  430. height: auto;
  431. `;
  432. }}
  433. `;
  434. const SearchListItem = styled(ListItem)<{isDisabled?: boolean; isGrouped?: boolean}>`
  435. scroll-margin: 40px 0;
  436. font-size: ${p => p.theme.fontSizeLarge};
  437. padding: 4px ${space(2)};
  438. min-height: ${p => (p.isGrouped ? '30px' : '36px')};
  439. ${p => {
  440. if (!p.isDisabled) {
  441. return `
  442. cursor: pointer;
  443. &:hover,
  444. &.active {
  445. background: ${p.theme.hover};
  446. }
  447. `;
  448. }
  449. return '';
  450. }}
  451. display: flex;
  452. flex-direction: row;
  453. justify-content: space-between;
  454. align-items: center;
  455. width: 100%;
  456. `;
  457. const SearchItemTitleWrapper = styled('div')<{hasSingleField?: boolean}>`
  458. display: flex;
  459. flex-grow: 1;
  460. flex-shrink: 0;
  461. max-width: ${p => (p.hasSingleField ? '75%' : 'min(280px, 50%)')};
  462. color: ${p => p.theme.textColor};
  463. font-weight: normal;
  464. font-size: ${p => p.theme.fontSizeMedium};
  465. margin: 0;
  466. line-height: ${p => p.theme.text.lineHeightHeading};
  467. ${p => p.theme.overflowEllipsis};
  468. `;
  469. const RestOfWordsContainer = styled('span')<{
  470. hasSplit?: boolean;
  471. isFirstWordHidden?: boolean;
  472. }>`
  473. color: ${p => (p.hasSplit ? p.theme.blue400 : p.theme.textColor)};
  474. margin-left: ${p => (p.isFirstWordHidden ? space(1) : '0px')};
  475. `;
  476. const FirstWordWrapper = styled('span')`
  477. font-weight: medium;
  478. `;
  479. const TagWrapper = styled('span')`
  480. flex-shrink: 0;
  481. display: flex;
  482. flex-direction: row;
  483. align-items: center;
  484. justify-content: flex-end;
  485. `;
  486. const Documentation = styled('span')`
  487. display: flex;
  488. flex: 2;
  489. padding: 0 ${space(1)};
  490. min-width: 0;
  491. ${p => p.theme.overflowEllipsis}
  492. font-size: ${p => p.theme.fontSizeMedium};
  493. font-family: ${p => p.theme.text.family};
  494. color: ${p => p.theme.subText};
  495. white-space: pre;
  496. `;
  497. const DropdownFooter = styled(`div`)`
  498. width: 100%;
  499. min-height: 45px;
  500. background-color: ${p => p.theme.backgroundSecondary};
  501. border-top: 1px solid ${p => p.theme.innerBorder};
  502. flex-direction: row;
  503. display: flex;
  504. align-items: center;
  505. justify-content: space-between;
  506. padding: ${space(1)};
  507. flex-wrap: wrap;
  508. gap: ${space(1)};
  509. `;
  510. const HotkeyGlyphWrapper = styled('span')`
  511. color: ${p => p.theme.gray300};
  512. margin-right: ${space(0.5)};
  513. @media (max-width: ${p => p.theme.breakpoints.small}) {
  514. display: none;
  515. }
  516. `;
  517. const IconWrapper = styled('span')`
  518. display: none;
  519. @media (max-width: ${p => p.theme.breakpoints.small}) {
  520. display: flex;
  521. margin-right: ${space(0.5)};
  522. align-items: center;
  523. justify-content: center;
  524. }
  525. `;
  526. const Invalid = styled(`span`)`
  527. font-size: ${p => p.theme.fontSizeSmall};
  528. font-family: ${p => p.theme.text.family};
  529. color: ${p => p.theme.gray400};
  530. display: flex;
  531. flex-direction: row;
  532. flex-wrap: wrap;
  533. span {
  534. white-space: pre;
  535. }
  536. `;
  537. const Highlight = styled(`strong`)`
  538. color: ${p => p.theme.linkColor};
  539. `;
  540. const QueryItemWrapper = styled('span')`
  541. font-size: ${p => p.theme.fontSizeSmall};
  542. width: 100%;
  543. gap: ${space(1)};
  544. display: flex;
  545. white-space: nowrap;
  546. word-break: normal;
  547. font-family: ${p => p.theme.text.familyMono};
  548. `;
  549. const Value = styled('span')<{hasDocs?: boolean}>`
  550. font-family: ${p => p.theme.text.familyMono};
  551. font-size: ${p => p.theme.fontSizeSmall};
  552. max-width: ${p => (p.hasDocs ? '280px' : 'none')};
  553. ${p => p.theme.overflowEllipsis};
  554. `;