savedIssueSearches.tsx 11 KB

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