savedIssueSearches.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import {Fragment} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {openModal} from 'sentry/actionCreators/modal';
  5. import Button from 'sentry/components/button';
  6. import {openConfirmModal} from 'sentry/components/confirm';
  7. import DropdownMenuControl from 'sentry/components/dropdownMenuControl';
  8. import {MenuItemProps} from 'sentry/components/dropdownMenuItem';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import CreateSavedSearchModal from 'sentry/components/modals/createSavedSearchModal';
  11. import {IconAdd, IconEllipsis} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import space from 'sentry/styles/space';
  14. import {Organization, SavedSearch} from 'sentry/types';
  15. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  16. interface SavedIssueSearchesProps {
  17. isOpen: boolean;
  18. onSavedSearchDelete: (savedSearch: SavedSearch) => void;
  19. onSavedSearchSelect: (savedSearch: SavedSearch) => void;
  20. organization: Organization;
  21. query: string;
  22. savedSearch: SavedSearch | null;
  23. savedSearchLoading: boolean;
  24. savedSearches: SavedSearch[];
  25. sort: string;
  26. }
  27. interface SavedSearchItemProps
  28. extends Pick<
  29. SavedIssueSearchesProps,
  30. 'organization' | 'onSavedSearchDelete' | 'onSavedSearchSelect'
  31. > {
  32. savedSearch: SavedSearch;
  33. }
  34. type CreateNewSavedSearchButtonProps = Pick<
  35. SavedIssueSearchesProps,
  36. 'query' | 'sort' | 'organization'
  37. >;
  38. const SavedSearchItem = ({
  39. organization,
  40. onSavedSearchDelete,
  41. onSavedSearchSelect,
  42. savedSearch,
  43. }: SavedSearchItemProps) => {
  44. const hasOrgWriteAccess = organization.access?.includes('org:write');
  45. const actions: MenuItemProps[] = [
  46. {
  47. key: 'edit',
  48. label: 'Edit',
  49. disabled: true,
  50. details: 'Not yet supported',
  51. },
  52. {
  53. disabled: !hasOrgWriteAccess,
  54. details: !hasOrgWriteAccess
  55. ? t('You do not have permission to delete this search.')
  56. : '',
  57. key: 'delete',
  58. label: t('Delete'),
  59. onAction: () => {
  60. openConfirmModal({
  61. message: t('Are you sure you want to delete this saved search?'),
  62. onConfirm: () => onSavedSearchDelete(savedSearch),
  63. });
  64. },
  65. priority: 'danger',
  66. },
  67. ];
  68. return (
  69. <SearchListItem>
  70. <StyledItemButton
  71. aria-label={savedSearch.name}
  72. onClick={() => onSavedSearchSelect(savedSearch)}
  73. hasMenu={!savedSearch.isGlobal}
  74. borderless
  75. align="left"
  76. >
  77. <TitleDescriptionWrapper>
  78. <SavedSearchItemTitle>{savedSearch.name}</SavedSearchItemTitle>
  79. <SavedSearchItemDescription>{savedSearch.query}</SavedSearchItemDescription>
  80. </TitleDescriptionWrapper>
  81. </StyledItemButton>
  82. {!savedSearch.isGlobal && (
  83. <OverflowMenu
  84. position="bottom-end"
  85. items={actions}
  86. size="sm"
  87. trigger={props => (
  88. <Button
  89. {...props}
  90. aria-label={t('Saved search options')}
  91. borderless
  92. icon={<IconEllipsis size="sm" />}
  93. size="sm"
  94. />
  95. )}
  96. />
  97. )}
  98. </SearchListItem>
  99. );
  100. };
  101. function CreateNewSavedSearchButton({
  102. organization,
  103. query,
  104. sort,
  105. }: CreateNewSavedSearchButtonProps) {
  106. const disabled = !organization.access.includes('org:write');
  107. const title = disabled
  108. ? t('You do not have permission to create a saved search')
  109. : t('Create a new saved search for your organization');
  110. const onClick = () => {
  111. trackAdvancedAnalyticsEvent('search.saved_search_open_create_modal', {
  112. organization,
  113. });
  114. openModal(deps => (
  115. <CreateSavedSearchModal {...deps} {...{organization, query, sort}} />
  116. ));
  117. };
  118. return (
  119. <Button
  120. aria-label={t('Create a new saved search for your organization')}
  121. disabled={disabled}
  122. onClick={onClick}
  123. icon={<IconAdd size="sm" />}
  124. title={title}
  125. borderless
  126. size="sm"
  127. />
  128. );
  129. }
  130. const SavedIssueSearches = ({
  131. organization,
  132. isOpen,
  133. onSavedSearchDelete,
  134. onSavedSearchSelect,
  135. savedSearchLoading,
  136. savedSearches,
  137. query,
  138. sort,
  139. }: SavedIssueSearchesProps) => {
  140. if (!isOpen) {
  141. return null;
  142. }
  143. if (!organization.features.includes('issue-list-saved-searches-v2')) {
  144. return null;
  145. }
  146. if (savedSearchLoading) {
  147. return (
  148. <StyledSidebar>
  149. <LoadingIndicator />
  150. </StyledSidebar>
  151. );
  152. }
  153. const orgSavedSearches = savedSearches.filter(
  154. search => !search.isGlobal && !search.isPinned
  155. );
  156. const recommendedSavedSearches = savedSearches.filter(search => search.isGlobal);
  157. return (
  158. <StyledSidebar>
  159. {orgSavedSearches.length > 0 && (
  160. <Fragment>
  161. <HeadingContainer>
  162. <Heading>{t('Saved Searches')}</Heading>
  163. <CreateNewSavedSearchButton {...{organization, query, sort}} />
  164. </HeadingContainer>
  165. <SearchesContainer>
  166. {orgSavedSearches.map(item => (
  167. <SavedSearchItem
  168. key={item.id}
  169. organization={organization}
  170. onSavedSearchDelete={onSavedSearchDelete}
  171. onSavedSearchSelect={onSavedSearchSelect}
  172. savedSearch={item}
  173. />
  174. ))}
  175. </SearchesContainer>
  176. </Fragment>
  177. )}
  178. {recommendedSavedSearches.length > 0 && (
  179. <Fragment>
  180. <HeadingContainer>
  181. <Heading>{t('Recommended')}</Heading>
  182. </HeadingContainer>
  183. <SearchesContainer>
  184. {recommendedSavedSearches.map(item => (
  185. <SavedSearchItem
  186. key={item.id}
  187. organization={organization}
  188. onSavedSearchDelete={onSavedSearchDelete}
  189. onSavedSearchSelect={onSavedSearchSelect}
  190. savedSearch={item}
  191. />
  192. ))}
  193. </SearchesContainer>
  194. </Fragment>
  195. )}
  196. </StyledSidebar>
  197. );
  198. };
  199. const StyledSidebar = styled('aside')`
  200. width: 360px;
  201. padding: ${space(3)} ${space(2)};
  202. @media (max-width: ${p => p.theme.breakpoints.small}) {
  203. border-bottom: 1px solid ${p => p.theme.gray200};
  204. padding: ${space(2)} 0;
  205. }
  206. @media (min-width: ${p => p.theme.breakpoints.small}) {
  207. border-left: 1px solid ${p => p.theme.gray200};
  208. }
  209. `;
  210. const HeadingContainer = styled('div')`
  211. display: flex;
  212. justify-content: space-between;
  213. align-items: center;
  214. height: 38px;
  215. &:first-of-type {
  216. margin-top: 0;
  217. }
  218. margin: ${space(3)} 0 ${space(2)} ${space(2)};
  219. `;
  220. const Heading = styled('h2')`
  221. font-size: ${p => p.theme.fontSizeExtraLarge};
  222. margin: 0;
  223. `;
  224. const SearchesContainer = styled('ul')`
  225. padding: 0;
  226. margin-bottom: ${space(1)};
  227. `;
  228. const SearchListItem = styled('li')`
  229. position: relative;
  230. list-style: none;
  231. padding: 0;
  232. margin: 0;
  233. `;
  234. const StyledItemButton = styled(Button)<{hasMenu?: boolean}>`
  235. display: block;
  236. width: 100%;
  237. text-align: left;
  238. height: auto;
  239. font-weight: normal;
  240. line-height: ${p => p.theme.text.lineHeightBody};
  241. margin-top: 2px;
  242. ${p =>
  243. p.hasMenu &&
  244. css`
  245. padding-right: 60px;
  246. `}
  247. `;
  248. const TitleDescriptionWrapper = styled('div')`
  249. overflow: hidden;
  250. `;
  251. const SavedSearchItemTitle = styled('div')`
  252. font-size: ${p => p.theme.fontSizeLarge};
  253. overflow: hidden;
  254. white-space: nowrap;
  255. text-overflow: ellipsis;
  256. `;
  257. const SavedSearchItemDescription = styled('div')`
  258. font-family: ${p => p.theme.text.familyMono};
  259. font-size: ${p => p.theme.fontSizeSmall};
  260. color: ${p => p.theme.subText};
  261. overflow: hidden;
  262. white-space: nowrap;
  263. text-overflow: ellipsis;
  264. `;
  265. const OverflowMenu = styled(DropdownMenuControl)`
  266. position: absolute;
  267. top: 12px;
  268. right: ${space(1)};
  269. `;
  270. export default SavedIssueSearches;