searchDropdown.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  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 {t, tct} from 'sentry/locale';
  6. import space from 'sentry/styles/space';
  7. import Button from '../button';
  8. import HotkeysLabel from '../hotkeysLabel';
  9. import {ItemType, SearchGroup, SearchItem, Shortcut} from './types';
  10. type Props = {
  11. items: SearchGroup[];
  12. loading: boolean;
  13. onClick: (value: string, item: SearchItem) => void;
  14. searchSubstring: string;
  15. className?: string;
  16. maxMenuHeight?: number;
  17. runShortcut?: (shortcut: Shortcut) => void;
  18. visibleShortcuts?: Shortcut[];
  19. };
  20. class SearchDropdown extends PureComponent<Props> {
  21. static defaultProps = {
  22. searchSubstring: '',
  23. onClick: function () {},
  24. };
  25. renderDescription = (item: SearchItem) => {
  26. const searchSubstring = this.props.searchSubstring;
  27. if (!searchSubstring) {
  28. if (item.type === ItemType.INVALID_TAG) {
  29. return (
  30. <Invalid>
  31. {tct("The field [field] isn't supported here. ", {
  32. field: <strong>{item.desc}</strong>,
  33. })}
  34. {tct('[highlight:See all searchable properties in the docs.]', {
  35. highlight: <Highlight />,
  36. })}
  37. </Invalid>
  38. );
  39. }
  40. return item.desc;
  41. }
  42. const text = item.desc;
  43. if (!text) {
  44. return null;
  45. }
  46. const idx = text.toLowerCase().indexOf(searchSubstring.toLowerCase());
  47. if (idx === -1) {
  48. return item.desc;
  49. }
  50. return (
  51. <span>
  52. {text.substr(0, idx)}
  53. <strong>{text.substr(idx, searchSubstring.length)}</strong>
  54. {text.substr(idx + searchSubstring.length)}
  55. </span>
  56. );
  57. };
  58. renderHeaderItem = (item: SearchGroup) => (
  59. <SearchDropdownGroup key={item.title}>
  60. <SearchDropdownGroupTitle>
  61. {item.icon}
  62. {item.title && item.title}
  63. {item.desc && <span>{item.desc}</span>}
  64. </SearchDropdownGroupTitle>
  65. </SearchDropdownGroup>
  66. );
  67. renderItem = (item: SearchItem) => (
  68. <SearchListItem
  69. key={item.value || item.desc || item.title}
  70. className={item.active ? 'active' : undefined}
  71. data-test-id="search-autocomplete-item"
  72. onClick={item.callback ?? this.props.onClick.bind(this, item.value, item)}
  73. ref={element => item.active && element?.scrollIntoView?.({block: 'nearest'})}
  74. >
  75. <SearchItemTitleWrapper>
  76. {item.title && `${item.title}${item.desc ? ' · ' : ''}`}
  77. <Description>{this.renderDescription(item)}</Description>
  78. <Documentation>{item.documentation}</Documentation>
  79. </SearchItemTitleWrapper>
  80. </SearchListItem>
  81. );
  82. render() {
  83. const {className, loading, items, runShortcut, visibleShortcuts, maxMenuHeight} =
  84. this.props;
  85. return (
  86. <StyledSearchDropdown className={className}>
  87. {loading ? (
  88. <LoadingWrapper key="loading" data-test-id="search-autocomplete-loading">
  89. <LoadingIndicator mini />
  90. </LoadingWrapper>
  91. ) : (
  92. <SearchItemsList maxMenuHeight={maxMenuHeight}>
  93. {items.map(item => {
  94. const isEmpty = item.children && !item.children.length;
  95. // Hide header if `item.children` is defined, an array, and is empty
  96. return (
  97. <Fragment key={item.title}>
  98. {item.type === 'header' && this.renderHeaderItem(item)}
  99. {item.children && item.children.map(this.renderItem)}
  100. {isEmpty && <Info>{t('No items found')}</Info>}
  101. </Fragment>
  102. );
  103. })}
  104. </SearchItemsList>
  105. )}
  106. <DropdownFooter>
  107. <ShortcutsRow>
  108. {runShortcut &&
  109. visibleShortcuts?.map(shortcut => {
  110. return (
  111. <ShortcutButtonContainer
  112. key={shortcut.text}
  113. onClick={() => runShortcut(shortcut)}
  114. >
  115. <HotkeyGlyphWrapper>
  116. <HotkeysLabel
  117. value={
  118. shortcut.hotkeys?.display ?? shortcut.hotkeys?.actual ?? []
  119. }
  120. />
  121. </HotkeyGlyphWrapper>
  122. <IconWrapper>{shortcut.icon}</IconWrapper>
  123. <HotkeyTitle>{shortcut.text}</HotkeyTitle>
  124. </ShortcutButtonContainer>
  125. );
  126. })}
  127. </ShortcutsRow>
  128. <Button
  129. size="xsmall"
  130. href="https://docs.sentry.io/product/sentry-basics/search/"
  131. >
  132. Read the docs
  133. </Button>
  134. </DropdownFooter>
  135. </StyledSearchDropdown>
  136. );
  137. }
  138. }
  139. export default SearchDropdown;
  140. const StyledSearchDropdown = styled('div')`
  141. /* Container has a border that we need to account for */
  142. position: absolute;
  143. top: 100%;
  144. left: -1px;
  145. right: -1px;
  146. z-index: ${p => p.theme.zIndex.dropdown};
  147. overflow: hidden;
  148. margin-top: ${space(1)};
  149. background: ${p => p.theme.background};
  150. box-shadow: ${p => p.theme.dropShadowHeavy};
  151. border: 1px solid ${p => p.theme.border};
  152. border-radius: ${p => p.theme.borderRadius};
  153. `;
  154. const LoadingWrapper = styled('div')`
  155. display: flex;
  156. justify-content: center;
  157. padding: ${space(1)};
  158. `;
  159. const Info = styled('div')`
  160. display: flex;
  161. padding: ${space(1)} ${space(2)};
  162. font-size: ${p => p.theme.fontSizeLarge};
  163. color: ${p => p.theme.gray300};
  164. &:not(:last-child) {
  165. border-bottom: 1px solid ${p => p.theme.innerBorder};
  166. }
  167. `;
  168. const ListItem = styled('li')`
  169. &:not(:last-child) {
  170. border-bottom: 1px solid ${p => p.theme.innerBorder};
  171. }
  172. `;
  173. const SearchDropdownGroup = styled(ListItem)``;
  174. const SearchDropdownGroupTitle = styled('header')`
  175. display: flex;
  176. align-items: center;
  177. background-color: ${p => p.theme.backgroundSecondary};
  178. color: ${p => p.theme.gray300};
  179. font-weight: normal;
  180. font-size: ${p => p.theme.fontSizeMedium};
  181. margin: 0;
  182. padding: ${space(1)} ${space(2)};
  183. & > svg {
  184. margin-right: ${space(1)};
  185. }
  186. `;
  187. const SearchItemsList = styled('ul')<{maxMenuHeight?: number}>`
  188. padding-left: 0;
  189. list-style: none;
  190. margin-bottom: 0;
  191. ${p => {
  192. if (p.maxMenuHeight !== undefined) {
  193. return `
  194. max-height: ${p.maxMenuHeight}px;
  195. overflow-y: scroll;
  196. `;
  197. }
  198. return `
  199. height: auto;
  200. `;
  201. }}
  202. `;
  203. const SearchListItem = styled(ListItem)`
  204. scroll-margin: 40px 0;
  205. font-size: ${p => p.theme.fontSizeLarge};
  206. padding: ${space(1)} ${space(2)};
  207. cursor: pointer;
  208. &:hover,
  209. &.active {
  210. background: ${p => p.theme.hover};
  211. }
  212. `;
  213. const SearchItemTitleWrapper = styled('div')`
  214. color: ${p => p.theme.textColor};
  215. font-weight: normal;
  216. font-size: ${p => p.theme.fontSizeMedium};
  217. margin: 0;
  218. line-height: ${p => p.theme.text.lineHeightHeading};
  219. ${p => p.theme.overflowEllipsis};
  220. `;
  221. const Description = styled('span')`
  222. font-size: ${p => p.theme.fontSizeSmall};
  223. font-family: ${p => p.theme.text.familyMono};
  224. `;
  225. const Documentation = styled('span')`
  226. font-size: ${p => p.theme.fontSizeSmall};
  227. font-family: ${p => p.theme.text.familyMono};
  228. float: right;
  229. color: ${p => p.theme.gray300};
  230. `;
  231. const DropdownFooter = styled(`div`)`
  232. width: 100%;
  233. min-height: 45px;
  234. background-color: ${p => p.theme.backgroundSecondary};
  235. border-top: 1px solid ${p => p.theme.innerBorder};
  236. flex-direction: row;
  237. display: flex;
  238. align-items: center;
  239. justify-content: space-between;
  240. padding: ${space(1)};
  241. flex-wrap: wrap;
  242. gap: ${space(1)};
  243. `;
  244. const ShortcutsRow = styled('div')`
  245. flex-direction: row;
  246. display: flex;
  247. align-items: center;
  248. `;
  249. const ShortcutButtonContainer = styled('div')`
  250. display: flex;
  251. flex-direction: row;
  252. align-items: center;
  253. height: auto;
  254. padding: 0 ${space(1.5)};
  255. cursor: pointer;
  256. :hover {
  257. border-radius: ${p => p.theme.borderRadius};
  258. background-color: ${p => color(p.theme.hover).darken(0.02).string()};
  259. }
  260. `;
  261. const HotkeyGlyphWrapper = styled('span')`
  262. color: ${p => p.theme.gray300};
  263. margin-right: ${space(0.5)};
  264. @media (max-width: ${p => p.theme.breakpoints.small}) {
  265. display: none;
  266. }
  267. `;
  268. const IconWrapper = styled('span')`
  269. display: none;
  270. @media (max-width: ${p => p.theme.breakpoints.small}) {
  271. display: flex;
  272. margin-right: ${space(0.5)};
  273. align-items: center;
  274. justify-content: center;
  275. }
  276. `;
  277. const HotkeyTitle = styled(`span`)`
  278. font-size: ${p => p.theme.fontSizeSmall};
  279. `;
  280. const Invalid = styled(`span`)`
  281. font-size: ${p => p.theme.fontSizeSmall};
  282. font-family: ${p => p.theme.text.family};
  283. color: ${p => p.theme.gray400};
  284. display: flex;
  285. flex-direction: row;
  286. flex-wrap: wrap;
  287. span {
  288. white-space: pre;
  289. }
  290. `;
  291. const Highlight = styled(`strong`)`
  292. color: ${p => p.theme.linkColor};
  293. `;