savedIssueSearches.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  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 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. align="left"
  100. >
  101. <TitleDescriptionWrapper>
  102. <SavedSearchItemTitle>{savedSearch.name}</SavedSearchItemTitle>
  103. <SavedSearchItemDescription savedSearch={savedSearch} />
  104. </TitleDescriptionWrapper>
  105. </StyledItemButton>
  106. {!savedSearch.isGlobal && (
  107. <OverflowMenu
  108. position="bottom-end"
  109. items={actions}
  110. size="sm"
  111. minMenuWidth={200}
  112. trigger={props => (
  113. <Button
  114. {...props}
  115. aria-label={t('Saved search options')}
  116. borderless
  117. icon={<IconEllipsis size="sm" />}
  118. size="sm"
  119. />
  120. )}
  121. />
  122. )}
  123. </SearchListItem>
  124. );
  125. };
  126. function CreateNewSavedSearchButton({
  127. organization,
  128. query,
  129. sort,
  130. }: CreateNewSavedSearchButtonProps) {
  131. const onClick = () => {
  132. trackAdvancedAnalyticsEvent('search.saved_search_open_create_modal', {
  133. organization,
  134. });
  135. openModal(deps => (
  136. <CreateSavedSearchModal {...deps} {...{organization, query, sort}} />
  137. ));
  138. };
  139. return (
  140. <Button
  141. aria-label={t('Create a new saved search')}
  142. onClick={onClick}
  143. icon={<IconAdd size="sm" />}
  144. borderless
  145. size="sm"
  146. />
  147. );
  148. }
  149. const SavedIssueSearches = ({
  150. organization,
  151. onSavedSearchSelect,
  152. query,
  153. sort,
  154. }: SavedIssueSearchesProps) => {
  155. const theme = useTheme();
  156. const [isOpen, setIsOpen] = useSyncedLocalStorageState(
  157. SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY,
  158. false
  159. );
  160. const [showAll, setShowAll] = useState(false);
  161. const {
  162. data: savedSearches,
  163. isLoading,
  164. isError,
  165. refetch,
  166. } = useFetchSavedSearchesForOrg({orgSlug: organization.slug});
  167. const isAboveContent = useMedia(`(max-width: ${theme.breakpoints.small})`);
  168. const onClickSavedSearch = (savedSearch: SavedSearch) => {
  169. // On small screens, the sidebar appears above the issue list, so we
  170. // will close it automatically for convenience.
  171. if (isAboveContent) {
  172. setIsOpen(false);
  173. }
  174. onSavedSearchSelect(savedSearch);
  175. };
  176. if (!isOpen) {
  177. return null;
  178. }
  179. if (isLoading) {
  180. return (
  181. <StyledSidebar>
  182. <LoadingIndicator />
  183. </StyledSidebar>
  184. );
  185. }
  186. if (isError) {
  187. return (
  188. <StyledSidebar>
  189. <LoadingError onRetry={refetch} />
  190. </StyledSidebar>
  191. );
  192. }
  193. const orgSavedSearches = orderBy(
  194. savedSearches.filter(search => !search.isGlobal && !search.isPinned),
  195. 'dateCreated',
  196. 'desc'
  197. );
  198. const recommendedSavedSearches = savedSearches.filter(search => search.isGlobal);
  199. const shownOrgSavedSearches = showAll
  200. ? orgSavedSearches
  201. : orgSavedSearches.slice(0, MAX_SHOWN_SEARCHES);
  202. return (
  203. <StyledSidebar>
  204. <Fragment>
  205. <HeadingContainer>
  206. <Heading>{t('Saved Searches')}</Heading>
  207. <CreateNewSavedSearchButton {...{organization, query, sort}} />
  208. </HeadingContainer>
  209. <SearchesContainer>
  210. {shownOrgSavedSearches.map(item => (
  211. <SavedSearchItem
  212. key={item.id}
  213. organization={organization}
  214. onSavedSearchSelect={onClickSavedSearch}
  215. savedSearch={item}
  216. />
  217. ))}
  218. {shownOrgSavedSearches.length === 0 && (
  219. <NoSavedSearchesText>
  220. {t("You don't have any saved searches")}
  221. </NoSavedSearchesText>
  222. )}
  223. </SearchesContainer>
  224. {orgSavedSearches.length > shownOrgSavedSearches.length && (
  225. <ShowAllButton size="zero" borderless onClick={() => setShowAll(true)}>
  226. {t(
  227. 'Show %s more',
  228. (orgSavedSearches.length - shownOrgSavedSearches.length).toLocaleString()
  229. )}
  230. </ShowAllButton>
  231. )}
  232. </Fragment>
  233. {recommendedSavedSearches.length > 0 && (
  234. <Fragment>
  235. <HeadingContainer>
  236. <Heading>{t('Recommended Searches')}</Heading>
  237. </HeadingContainer>
  238. <SearchesContainer>
  239. {recommendedSavedSearches.map(item => (
  240. <SavedSearchItem
  241. key={item.id}
  242. organization={organization}
  243. onSavedSearchSelect={onClickSavedSearch}
  244. savedSearch={item}
  245. />
  246. ))}
  247. </SearchesContainer>
  248. </Fragment>
  249. )}
  250. </StyledSidebar>
  251. );
  252. };
  253. const StyledSidebar = styled('aside')`
  254. grid-area: saved-searches;
  255. width: 100%;
  256. padding: ${space(2)};
  257. @media (max-width: ${p => p.theme.breakpoints.small}) {
  258. border-bottom: 1px solid ${p => p.theme.gray200};
  259. padding: ${space(2)} 0;
  260. }
  261. @media (min-width: ${p => p.theme.breakpoints.small}) {
  262. border-left: 1px solid ${p => p.theme.gray200};
  263. max-width: 340px;
  264. }
  265. `;
  266. const HeadingContainer = styled('div')`
  267. display: flex;
  268. justify-content: space-between;
  269. align-items: center;
  270. height: 38px;
  271. padding-left: ${space(2)};
  272. margin-top: ${space(3)};
  273. &:first-of-type {
  274. margin-top: 0;
  275. }
  276. `;
  277. const Heading = styled('h2')`
  278. font-size: ${p => p.theme.fontSizeExtraLarge};
  279. margin: 0;
  280. `;
  281. const SearchesContainer = styled('ul')`
  282. padding: 0;
  283. margin-bottom: ${space(1)};
  284. `;
  285. const StyledItemButton = styled(Button)`
  286. display: block;
  287. width: 100%;
  288. text-align: left;
  289. height: auto;
  290. font-weight: normal;
  291. line-height: ${p => p.theme.text.lineHeightBody};
  292. padding: ${space(1)} ${space(2)};
  293. `;
  294. const OverflowMenu = styled(DropdownMenuControl)`
  295. position: absolute;
  296. top: 12px;
  297. right: ${space(1)};
  298. `;
  299. const SearchListItem = styled('li')<{hasMenu?: boolean}>`
  300. position: relative;
  301. list-style: none;
  302. padding: 0;
  303. margin: 0;
  304. ${p =>
  305. p.hasMenu &&
  306. css`
  307. @media (max-width: ${p.theme.breakpoints.small}) {
  308. ${StyledItemButton} {
  309. padding-right: 60px;
  310. }
  311. }
  312. @media (min-width: ${p.theme.breakpoints.small}) {
  313. ${OverflowMenu} {
  314. display: none;
  315. }
  316. &:hover,
  317. &:focus-within {
  318. ${OverflowMenu} {
  319. display: block;
  320. }
  321. ${StyledItemButton} {
  322. padding-right: 60px;
  323. }
  324. }
  325. }
  326. `}
  327. `;
  328. const TitleDescriptionWrapper = styled('div')`
  329. overflow: hidden;
  330. `;
  331. const SavedSearchItemTitle = styled('div')`
  332. font-size: ${p => p.theme.fontSizeLarge};
  333. ${p => p.theme.overflowEllipsis}
  334. `;
  335. const SavedSearchItemVisbility = styled('div')`
  336. color: ${p => p.theme.subText};
  337. font-size: ${p => p.theme.fontSizeSmall};
  338. ${p => p.theme.overflowEllipsis}
  339. `;
  340. const SavedSearchItemQuery = styled('div')`
  341. font-family: ${p => p.theme.text.familyMono};
  342. font-size: ${p => p.theme.fontSizeSmall};
  343. color: ${p => p.theme.subText};
  344. ${p => p.theme.overflowEllipsis}
  345. `;
  346. const ShowAllButton = styled(Button)`
  347. color: ${p => p.theme.linkColor};
  348. font-weight: normal;
  349. padding: ${space(0.5)} ${space(2)};
  350. &:hover {
  351. color: ${p => p.theme.linkHoverColor};
  352. }
  353. `;
  354. const NoSavedSearchesText = styled('p')`
  355. padding: 0 ${space(2)};
  356. margin: ${space(0.5)} 0;
  357. color: ${p => p.theme.subText};
  358. `;
  359. export default SavedIssueSearches;