savedSearchMenu.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import Access from 'sentry/components/acl/access';
  4. import Button from 'sentry/components/button';
  5. import Confirm from 'sentry/components/confirm';
  6. import MenuItem from 'sentry/components/menuItem';
  7. import Tooltip from 'sentry/components/tooltip';
  8. import {IconDelete} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import overflowEllipsis from 'sentry/styles/overflowEllipsis';
  11. import space from 'sentry/styles/space';
  12. import {Organization, SavedSearch} from 'sentry/types';
  13. import {getSortLabel} from './utils';
  14. type MenuItemProps = Omit<Props, 'savedSearchList'> & {
  15. isLast: boolean;
  16. search: SavedSearch;
  17. };
  18. function SavedSearchMenuItem({
  19. organization,
  20. onSavedSearchSelect,
  21. onSavedSearchDelete,
  22. search,
  23. query,
  24. sort,
  25. isLast,
  26. }: MenuItemProps) {
  27. return (
  28. <Tooltip
  29. title={
  30. <Fragment>
  31. {`${search.name} \u2022 `}
  32. <TooltipSearchQuery>{search.query}</TooltipSearchQuery>
  33. {` \u2022 `}
  34. {t('Sort: ')}
  35. {getSortLabel(search.sort)}
  36. </Fragment>
  37. }
  38. containerDisplayMode="block"
  39. delay={1000}
  40. >
  41. <StyledMenuItem
  42. isActive={search.query === query && search.sort === sort}
  43. isLast={isLast}
  44. data-test-id={`saved-search-${search.id}`}
  45. >
  46. <MenuItemLink tabIndex={-1} onClick={() => onSavedSearchSelect(search)}>
  47. <SearchTitle>{search.name}</SearchTitle>
  48. <SearchQueryContainer>
  49. <SearchQuery>{search.query}</SearchQuery>
  50. <SearchSort>
  51. {t('Sort: ')}
  52. {getSortLabel(search.sort)}
  53. </SearchSort>
  54. </SearchQueryContainer>
  55. </MenuItemLink>
  56. {search.isGlobal === false && search.isPinned === false && (
  57. <Access
  58. organization={organization}
  59. access={['org:write']}
  60. renderNoAccessMessage={false}
  61. >
  62. <Confirm
  63. onConfirm={() => onSavedSearchDelete(search)}
  64. message={t('Are you sure you want to delete this saved search?')}
  65. stopPropagation
  66. >
  67. <DeleteButton
  68. borderless
  69. title={t('Delete this saved search')}
  70. icon={<IconDelete />}
  71. aria-label={t('delete')}
  72. size="zero"
  73. />
  74. </Confirm>
  75. </Access>
  76. )}
  77. </StyledMenuItem>
  78. </Tooltip>
  79. );
  80. }
  81. type Props = {
  82. onSavedSearchDelete: (savedSearch: SavedSearch) => void;
  83. onSavedSearchSelect: (savedSearch: SavedSearch) => void;
  84. organization: Organization;
  85. savedSearchList: SavedSearch[];
  86. sort: string;
  87. query?: string;
  88. };
  89. function SavedSearchMenu({savedSearchList, ...props}: Props) {
  90. const savedSearches = savedSearchList.filter(search => !search.isGlobal);
  91. let globalSearches = savedSearchList.filter(search => search.isGlobal);
  92. // Hide "Unresolved Issues" since they have a unresolved tab
  93. globalSearches = globalSearches.filter(
  94. search => !search.isPinned && search.query !== 'is:unresolved'
  95. );
  96. return (
  97. <Fragment>
  98. <MenuHeader>{t('Saved Searches')}</MenuHeader>
  99. {savedSearches.length === 0 ? (
  100. <EmptyItem>{t('No saved searches yet.')}</EmptyItem>
  101. ) : (
  102. savedSearches.map((search, index) => (
  103. <SavedSearchMenuItem
  104. key={search.id}
  105. search={search}
  106. isLast={index === savedSearches.length - 1}
  107. {...props}
  108. />
  109. ))
  110. )}
  111. <SecondaryMenuHeader>{t('Recommended Searches')}</SecondaryMenuHeader>
  112. {/* Could only happen on self-hosted */}
  113. {globalSearches.length === 0 ? (
  114. <EmptyItem>{t('No recommended searches yet.')}</EmptyItem>
  115. ) : (
  116. globalSearches.map((search, index) => (
  117. <SavedSearchMenuItem
  118. key={search.id}
  119. search={search}
  120. isLast={index === globalSearches.length - 1}
  121. {...props}
  122. />
  123. ))
  124. )}
  125. </Fragment>
  126. );
  127. }
  128. export default SavedSearchMenu;
  129. const SearchTitle = styled('div')`
  130. color: ${p => p.theme.textColor};
  131. ${overflowEllipsis}
  132. `;
  133. const SearchQueryContainer = styled('div')`
  134. font-size: ${p => p.theme.fontSizeExtraSmall};
  135. ${overflowEllipsis}
  136. `;
  137. const SearchQuery = styled('code')`
  138. color: ${p => p.theme.subText};
  139. font-size: ${p => p.theme.fontSizeExtraSmall};
  140. padding: 0;
  141. background: inherit;
  142. `;
  143. const SearchSort = styled('span')`
  144. color: ${p => p.theme.subText};
  145. font-size: ${p => p.theme.fontSizeExtraSmall};
  146. &:before {
  147. font-size: ${p => p.theme.fontSizeExtraSmall};
  148. color: ${p => p.theme.textColor};
  149. content: ' \u2022 ';
  150. }
  151. `;
  152. const TooltipSearchQuery = styled('span')`
  153. color: ${p => p.theme.subText};
  154. font-weight: normal;
  155. font-family: ${p => p.theme.text.familyMono};
  156. `;
  157. const DeleteButton = styled(Button)`
  158. color: ${p => p.theme.gray200};
  159. background: transparent;
  160. flex-shrink: 0;
  161. padding: ${space(1)} 0;
  162. &:hover {
  163. background: transparent;
  164. color: ${p => p.theme.blue300};
  165. }
  166. `;
  167. const MenuHeader = styled('div')`
  168. align-items: center;
  169. color: ${p => p.theme.gray400};
  170. background: ${p => p.theme.backgroundSecondary};
  171. line-height: 0.75;
  172. padding: ${space(1.5)} ${space(2)};
  173. border-bottom: 1px solid ${p => p.theme.innerBorder};
  174. border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0;
  175. `;
  176. const SecondaryMenuHeader = styled(MenuHeader)`
  177. border-top: 1px solid ${p => p.theme.innerBorder};
  178. border-radius: 0;
  179. `;
  180. const StyledMenuItem = styled(MenuItem)<{isActive: boolean; isLast: boolean}>`
  181. border-bottom: ${p => (!p.isLast ? `1px solid ${p.theme.innerBorder}` : null)};
  182. font-size: ${p => p.theme.fontSizeMedium};
  183. & > span {
  184. padding: ${space(1)} ${space(2)};
  185. }
  186. ${p =>
  187. p.isActive &&
  188. `
  189. ${SearchTitle}, ${SearchQuery}, ${SearchSort} {
  190. color: ${p.theme.white};
  191. }
  192. ${SearchSort}:before {
  193. color: ${p.theme.white};
  194. }
  195. &:hover {
  196. ${SearchTitle}, ${SearchQuery}, ${SearchSort} {
  197. color: ${p.theme.black};
  198. }
  199. ${SearchSort}:before {
  200. color: ${p.theme.black};
  201. }
  202. }
  203. `}
  204. `;
  205. const MenuItemLink = styled('a')`
  206. display: block;
  207. flex-grow: 1;
  208. /* Nav tabs style override */
  209. border: 0;
  210. ${overflowEllipsis}
  211. `;
  212. const EmptyItem = styled('li')`
  213. padding: ${space(1)} ${space(1.5)};
  214. color: ${p => p.theme.subText};
  215. `;