searchDropdown.tsx 15 KB

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