savedIssueSearches.tsx 8.4 KB

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