searchDropdown.tsx 18 KB

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