savedIssueSearches.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import {Fragment, useState} from 'react';
  2. import {css, useTheme} 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, {ButtonLabel} 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 LoadingError from 'sentry/components/loadingError';
  11. import LoadingIndicator from 'sentry/components/loadingIndicator';
  12. import {CreateSavedSearchModal} from 'sentry/components/modals/savedSearchModal/createSavedSearchModal';
  13. import {EditSavedSearchModal} from 'sentry/components/modals/savedSearchModal/editSavedSearchModal';
  14. import {IconAdd, IconEllipsis} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import space from 'sentry/styles/space';
  17. import {Organization, SavedSearch, SavedSearchVisibility} from 'sentry/types';
  18. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  19. import useMedia from 'sentry/utils/useMedia';
  20. import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState';
  21. import {useDeleteSavedSearchOptimistic} from 'sentry/views/issueList/mutations/useDeleteSavedSearch';
  22. import {useFetchSavedSearchesForOrg} from 'sentry/views/issueList/queries/useFetchSavedSearchesForOrg';
  23. import {SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY} from 'sentry/views/issueList/utils';
  24. interface SavedIssueSearchesProps {
  25. onSavedSearchSelect: (savedSearch: SavedSearch) => void;
  26. organization: Organization;
  27. query: string;
  28. sort: string;
  29. }
  30. interface SavedSearchItemProps
  31. extends Pick<SavedIssueSearchesProps, 'organization' | 'onSavedSearchSelect'> {
  32. savedSearch: SavedSearch;
  33. }
  34. type CreateNewSavedSearchButtonProps = Pick<
  35. SavedIssueSearchesProps,
  36. 'query' | 'sort' | 'organization'
  37. >;
  38. const MAX_SHOWN_SEARCHES = 4;
  39. const SavedSearchItemDescription = ({
  40. savedSearch,
  41. }: Pick<SavedSearchItemProps, 'savedSearch'>) => {
  42. if (savedSearch.isGlobal) {
  43. return <SavedSearchItemQuery>{savedSearch.query}</SavedSearchItemQuery>;
  44. }
  45. return (
  46. <SavedSearchItemVisbility>
  47. {savedSearch.visibility === SavedSearchVisibility.Organization
  48. ? t('Anyone in organization can see but not edit')
  49. : t('Only you can see and edit')}
  50. </SavedSearchItemVisbility>
  51. );
  52. };
  53. const SavedSearchItem = ({
  54. organization,
  55. onSavedSearchSelect,
  56. savedSearch,
  57. }: SavedSearchItemProps) => {
  58. const {mutate: deleteSavedSearch} = useDeleteSavedSearchOptimistic();
  59. const hasOrgWriteAccess = organization.access?.includes('org:write');
  60. const canEdit =
  61. savedSearch.visibility === SavedSearchVisibility.Owner || hasOrgWriteAccess;
  62. const actions: MenuItemProps[] = [
  63. {
  64. key: 'edit',
  65. label: 'Edit',
  66. disabled: !canEdit,
  67. details: !canEdit
  68. ? t('You do not have permission to edit this search.')
  69. : undefined,
  70. onAction: () => {
  71. openModal(deps => (
  72. <EditSavedSearchModal {...deps} {...{organization, savedSearch}} />
  73. ));
  74. },
  75. },
  76. {
  77. disabled: !canEdit,
  78. details: !canEdit
  79. ? t('You do not have permission to delete this search.')
  80. : undefined,
  81. key: 'delete',
  82. label: t('Delete'),
  83. onAction: () => {
  84. openConfirmModal({
  85. message: t('Are you sure you want to delete this saved search?'),
  86. onConfirm: () =>
  87. deleteSavedSearch({orgSlug: organization.slug, id: savedSearch.id}),
  88. });
  89. },
  90. priority: 'danger',
  91. },
  92. ];
  93. return (
  94. <SearchListItem hasMenu={!savedSearch.isGlobal}>
  95. <StyledItemButton
  96. aria-label={savedSearch.name}
  97. onClick={() => onSavedSearchSelect(savedSearch)}
  98. borderless
  99. >
  100. <TitleDescriptionWrapper>
  101. <SavedSearchItemTitle>{savedSearch.name}</SavedSearchItemTitle>
  102. <SavedSearchItemDescription savedSearch={savedSearch} />
  103. </TitleDescriptionWrapper>
  104. </StyledItemButton>
  105. {!savedSearch.isGlobal && (
  106. <OverflowMenu
  107. position="bottom-end"
  108. items={actions}
  109. size="sm"
  110. minMenuWidth={200}
  111. trigger={props => (
  112. <Button
  113. {...props}
  114. aria-label={t('Saved search options')}
  115. borderless
  116. icon={<IconEllipsis size="sm" />}
  117. size="sm"
  118. />
  119. )}
  120. />
  121. )}
  122. </SearchListItem>
  123. );
  124. };
  125. function CreateNewSavedSearchButton({
  126. organization,
  127. query,
  128. sort,
  129. }: CreateNewSavedSearchButtonProps) {
  130. const onClick = () => {
  131. trackAdvancedAnalyticsEvent('search.saved_search_open_create_modal', {
  132. organization,
  133. });
  134. openModal(deps => (
  135. <CreateSavedSearchModal {...deps} {...{organization, query, sort}} />
  136. ));
  137. };
  138. return (
  139. <Button
  140. aria-label={t('Create a new saved search')}
  141. onClick={onClick}
  142. icon={<IconAdd size="sm" />}
  143. borderless
  144. size="sm"
  145. />
  146. );
  147. }
  148. const SavedIssueSearches = ({
  149. organization,
  150. onSavedSearchSelect,
  151. query,
  152. sort,
  153. }: SavedIssueSearchesProps) => {
  154. const theme = useTheme();
  155. const [isOpen, setIsOpen] = useSyncedLocalStorageState(
  156. SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY,
  157. false
  158. );
  159. const [showAll, setShowAll] = useState(false);
  160. const {
  161. data: savedSearches,
  162. isLoading,
  163. isError,
  164. refetch,
  165. } = useFetchSavedSearchesForOrg({orgSlug: organization.slug});
  166. const isAboveContent = useMedia(`(max-width: ${theme.breakpoints.small})`);
  167. const onClickSavedSearch = (savedSearch: SavedSearch) => {
  168. // On small screens, the sidebar appears above the issue list, so we
  169. // will close it automatically for convenience.
  170. if (isAboveContent) {
  171. setIsOpen(false);
  172. }
  173. onSavedSearchSelect(savedSearch);
  174. };
  175. if (!isOpen) {
  176. return null;
  177. }
  178. if (isLoading) {
  179. return (
  180. <StyledSidebar>
  181. <LoadingIndicator />
  182. </StyledSidebar>
  183. );
  184. }
  185. if (isError) {
  186. return (
  187. <StyledSidebar>
  188. <LoadingError onRetry={refetch} />
  189. </StyledSidebar>
  190. );
  191. }
  192. const orgSavedSearches = orderBy(
  193. savedSearches.filter(search => !search.isGlobal && !search.isPinned),
  194. 'dateCreated',
  195. 'desc'
  196. );
  197. const recommendedSavedSearches = savedSearches.filter(search => search.isGlobal);
  198. const shownOrgSavedSearches = showAll
  199. ? orgSavedSearches
  200. : orgSavedSearches.slice(0, MAX_SHOWN_SEARCHES);
  201. return (
  202. <StyledSidebar>
  203. <Fragment>
  204. <HeadingContainer>
  205. <Heading>{t('Saved Searches')}</Heading>
  206. <CreateNewSavedSearchButton {...{organization, query, sort}} />
  207. </HeadingContainer>
  208. <SearchesContainer>
  209. {shownOrgSavedSearches.map(item => (
  210. <SavedSearchItem
  211. key={item.id}
  212. organization={organization}
  213. onSavedSearchSelect={onClickSavedSearch}
  214. savedSearch={item}
  215. />
  216. ))}
  217. {shownOrgSavedSearches.length === 0 && (
  218. <NoSavedSearchesText>
  219. {t("You don't have any saved searches")}
  220. </NoSavedSearchesText>
  221. )}
  222. </SearchesContainer>
  223. {orgSavedSearches.length > shownOrgSavedSearches.length && (
  224. <ShowAllButton size="zero" borderless onClick={() => setShowAll(true)}>
  225. {t(
  226. 'Show %s more',
  227. (orgSavedSearches.length - shownOrgSavedSearches.length).toLocaleString()
  228. )}
  229. </ShowAllButton>
  230. )}
  231. </Fragment>
  232. {recommendedSavedSearches.length > 0 && (
  233. <Fragment>
  234. <HeadingContainer>
  235. <Heading>{t('Recommended Searches')}</Heading>
  236. </HeadingContainer>
  237. <SearchesContainer>
  238. {recommendedSavedSearches.map(item => (
  239. <SavedSearchItem
  240. key={item.id}
  241. organization={organization}
  242. onSavedSearchSelect={onClickSavedSearch}
  243. savedSearch={item}
  244. />
  245. ))}
  246. </SearchesContainer>
  247. </Fragment>
  248. )}
  249. </StyledSidebar>
  250. );
  251. };
  252. const StyledSidebar = styled('aside')`
  253. grid-area: saved-searches;
  254. width: 100%;
  255. padding: ${space(2)};
  256. @media (max-width: ${p => p.theme.breakpoints.small}) {
  257. border-bottom: 1px solid ${p => p.theme.gray200};
  258. padding: ${space(2)} 0;
  259. }
  260. @media (min-width: ${p => p.theme.breakpoints.small}) {
  261. border-left: 1px solid ${p => p.theme.gray200};
  262. max-width: 340px;
  263. }
  264. `;
  265. const HeadingContainer = styled('div')`
  266. display: flex;
  267. justify-content: space-between;
  268. align-items: center;
  269. height: 38px;
  270. padding-left: ${space(2)};
  271. margin-top: ${space(3)};
  272. &:first-of-type {
  273. margin-top: 0;
  274. }
  275. `;
  276. const Heading = styled('h2')`
  277. font-size: ${p => p.theme.fontSizeExtraLarge};
  278. margin: 0;
  279. `;
  280. const SearchesContainer = styled('ul')`
  281. padding: 0;
  282. margin-bottom: ${space(1)};
  283. `;
  284. const StyledItemButton = styled(Button)`
  285. display: block;
  286. width: 100%;
  287. text-align: left;
  288. height: auto;
  289. font-weight: normal;
  290. line-height: ${p => p.theme.text.lineHeightBody};
  291. padding: ${space(1)} ${space(2)};
  292. ${ButtonLabel} {
  293. justify-content: start;
  294. }
  295. `;
  296. const OverflowMenu = styled(DropdownMenuControl)`
  297. position: absolute;
  298. top: 12px;
  299. right: ${space(1)};
  300. `;
  301. const SearchListItem = styled('li')<{hasMenu?: boolean}>`
  302. position: relative;
  303. list-style: none;
  304. padding: 0;
  305. margin: 0;
  306. ${p =>
  307. p.hasMenu &&
  308. css`
  309. @media (max-width: ${p.theme.breakpoints.small}) {
  310. ${StyledItemButton} {
  311. padding-right: 60px;
  312. }
  313. }
  314. @media (min-width: ${p.theme.breakpoints.small}) {
  315. ${OverflowMenu} {
  316. display: none;
  317. }
  318. &:hover,
  319. &:focus-within {
  320. ${OverflowMenu} {
  321. display: block;
  322. }
  323. ${StyledItemButton} {
  324. padding-right: 60px;
  325. }
  326. }
  327. }
  328. `}
  329. `;
  330. const TitleDescriptionWrapper = styled('div')`
  331. overflow: hidden;
  332. `;
  333. const SavedSearchItemTitle = styled('div')`
  334. font-size: ${p => p.theme.fontSizeLarge};
  335. ${p => p.theme.overflowEllipsis}
  336. `;
  337. const SavedSearchItemVisbility = styled('div')`
  338. color: ${p => p.theme.subText};
  339. font-size: ${p => p.theme.fontSizeSmall};
  340. ${p => p.theme.overflowEllipsis}
  341. `;
  342. const SavedSearchItemQuery = styled('div')`
  343. font-family: ${p => p.theme.text.familyMono};
  344. font-size: ${p => p.theme.fontSizeSmall};
  345. color: ${p => p.theme.subText};
  346. ${p => p.theme.overflowEllipsis}
  347. `;
  348. const ShowAllButton = styled(Button)`
  349. color: ${p => p.theme.linkColor};
  350. font-weight: normal;
  351. padding: ${space(0.5)} ${space(2)};
  352. &:hover {
  353. color: ${p => p.theme.linkHoverColor};
  354. }
  355. `;
  356. const NoSavedSearchesText = styled('p')`
  357. padding: 0 ${space(2)};
  358. margin: ${space(0.5)} 0;
  359. color: ${p => p.theme.subText};
  360. `;
  361. export default SavedIssueSearches;