savedSearchMenu.tsx 6.2 KB

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