addViewPage.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import {useContext} from 'react';
  2. import styled from '@emotion/styled';
  3. import bannerStar from 'sentry-images/spot/banner-star.svg';
  4. import {usePrompt} from 'sentry/actionCreators/prompts';
  5. import {Button, LinkButton} from 'sentry/components/button';
  6. import {openConfirmModal} from 'sentry/components/confirm';
  7. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  8. import ExternalLink from 'sentry/components/links/externalLink';
  9. import QuestionTooltip from 'sentry/components/questionTooltip';
  10. import {FormattedQuery} from 'sentry/components/searchQueryBuilder/formattedQuery';
  11. import {IconClose} from 'sentry/icons';
  12. import {t, tn} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {SavedSearch} from 'sentry/types/group';
  15. import {trackAnalytics} from 'sentry/utils/analytics';
  16. import useOrganization from 'sentry/utils/useOrganization';
  17. import {OverflowEllipsisTextContainer} from 'sentry/views/insights/common/components/textAlign';
  18. import {NewTabContext} from 'sentry/views/issueList/utils/newTabContext';
  19. type SearchSuggestion = {
  20. label: string;
  21. query: string;
  22. };
  23. interface SearchSuggestionListProps {
  24. searchSuggestions: SearchSuggestion[];
  25. title: React.ReactNode;
  26. type: 'recommended' | 'saved_searches';
  27. }
  28. const RECOMMENDED_SEARCHES: SearchSuggestion[] = [
  29. {label: 'Prioritized', query: 'is:unresolved issue.priority:[high, medium]'},
  30. {label: 'Assigned to Me', query: 'is:unresolved assigned_or_suggested:me'},
  31. {
  32. label: 'For Review',
  33. query: 'is:unresolved is:for_review assigned_or_suggested:[me, my_teams, none]',
  34. },
  35. {label: 'Request Errors', query: 'is:unresolved http.status_code:5*'},
  36. {label: 'High Volume Issues', query: 'is:unresolved timesSeen:>100'},
  37. {label: 'Recent Errors', query: 'is:unresolved issue.category:error firstSeen:-24h'},
  38. {label: 'Function Regressions', query: 'issue.type:profile_function_regression'},
  39. ];
  40. function AddViewPage({savedSearches}: {savedSearches: SavedSearch[]}) {
  41. const toolTipContents = (
  42. <Container>
  43. {t(
  44. 'Saved searches will be deprecated soon. For any you wish to return to, please save them as views.'
  45. )}
  46. <ExternalLink href={'https://docs.sentry.io/product/issues/issue-views'}>
  47. {t('Learn More')}
  48. </ExternalLink>
  49. </Container>
  50. );
  51. const savedSearchTitle = (
  52. <SavedSearchesTitle>
  53. {t('Saved Searches (will be removed)')}
  54. <QuestionTooltip
  55. icon="info"
  56. title={toolTipContents}
  57. size="sm"
  58. position="top"
  59. skipWrapper
  60. isHoverable
  61. />
  62. </SavedSearchesTitle>
  63. );
  64. return (
  65. <AddViewWrapper>
  66. <AddViewBanner hasSavedSearches={savedSearches && savedSearches.length !== 0} />
  67. <SearchSuggestionList
  68. title={'Recommended Searches'}
  69. searchSuggestions={RECOMMENDED_SEARCHES}
  70. type="recommended"
  71. />
  72. {savedSearches && savedSearches.length !== 0 && (
  73. <SearchSuggestionList
  74. title={savedSearchTitle}
  75. searchSuggestions={savedSearches.map(search => {
  76. return {
  77. label: search.name,
  78. query: search.query,
  79. };
  80. })}
  81. type="saved_searches"
  82. />
  83. )}
  84. </AddViewWrapper>
  85. );
  86. }
  87. function AddViewBanner({hasSavedSearches}: {hasSavedSearches: boolean}) {
  88. const organization = useOrganization();
  89. const {isPromptDismissed, dismissPrompt} = usePrompt({
  90. feature: 'issue_views_add_view_banner',
  91. organization,
  92. });
  93. return !isPromptDismissed ? (
  94. <Banner>
  95. <BannerStar1 src={bannerStar} />
  96. <BannerStar2 src={bannerStar} />
  97. <BannerStar3 src={bannerStar} />
  98. <Title>
  99. {t('Welcome to the new Issue Views experience (Early Adopter only)')}
  100. <DismissButton
  101. analyticsEventKey="issue_views.add_view.banner_dismissed"
  102. analyticsEventName="'Issue Views: Add View Banner Dismissed"
  103. size="zero"
  104. borderless
  105. icon={<IconClose size="xs" />}
  106. aria-label={t('Dismiss')}
  107. onClick={() => dismissPrompt()}
  108. />
  109. </Title>
  110. <SubTitle>
  111. <div>
  112. {t(
  113. 'Issues just got a lot more personalized! Save your frequent issue searches for quick access.'
  114. )}
  115. </div>
  116. <div>{t('A few notes before you get started:')}</div>
  117. <AFewNotesList>
  118. <li>
  119. <BannerNoteBold>{t('Views are for your eyes only. ')}</BannerNoteBold>
  120. {t("No need to worry about messing up other team members' views")}
  121. </li>
  122. <li>
  123. <BannerNoteBold>{t('Drag your views to reorder. ')}</BannerNoteBold>{' '}
  124. {t('The leftmost view is your “default” experience')}
  125. </li>
  126. {hasSavedSearches && (
  127. <li>
  128. <BannerNoteBold>
  129. {t('Saved searches will be deprecated in the future. ')}
  130. </BannerNoteBold>{' '}
  131. {t(
  132. 'You can save them as views from the list below (only appears if you have saved searches)'
  133. )}
  134. </li>
  135. )}
  136. </AFewNotesList>
  137. </SubTitle>
  138. <FittedLinkButton
  139. size="sm"
  140. href="https://docs.sentry.io/product/issues/issue-views"
  141. external
  142. >
  143. {t('Read Docs')}
  144. </FittedLinkButton>
  145. </Banner>
  146. ) : null;
  147. }
  148. function SearchSuggestionList({
  149. title,
  150. searchSuggestions,
  151. type,
  152. }: SearchSuggestionListProps) {
  153. const {onNewViewsSaved} = useContext(NewTabContext);
  154. const organization = useOrganization();
  155. const analyticsKey =
  156. type === 'recommended'
  157. ? 'issue_views.add_view.recommended_view_saved'
  158. : 'issue_views.add_view.saved_search_saved';
  159. const analyticsEventName =
  160. type === 'recommended'
  161. ? 'Issue Views: Recommended View Saved'
  162. : 'Issue Views: Saved Search Saved';
  163. return (
  164. <Suggestions>
  165. <TitleWrapper>
  166. {title}
  167. {type === 'saved_searches' && (
  168. <StyledButton
  169. size="zero"
  170. onClick={e => {
  171. e.stopPropagation();
  172. openConfirmModal({
  173. message: (
  174. <ConfirmModalMessage>
  175. {tn(
  176. 'Save %s saved search as a view?',
  177. 'Save %s saved searches as views?',
  178. searchSuggestions.length
  179. )}
  180. </ConfirmModalMessage>
  181. ),
  182. onConfirm: () => {
  183. onNewViewsSaved?.(
  184. searchSuggestions.map(suggestion => ({
  185. ...suggestion,
  186. saveQueryToView: true,
  187. }))
  188. );
  189. },
  190. });
  191. }}
  192. analyticsEventKey="issue_views.add_view.all_saved_searches_saved"
  193. analyticsEventName="Issue Views: All Saved Searches Saved"
  194. borderless
  195. >
  196. {t('Save all')}
  197. </StyledButton>
  198. )}
  199. </TitleWrapper>
  200. <SuggestionList>
  201. {searchSuggestions.map((suggestion, index) => (
  202. <Suggestion
  203. key={index}
  204. onClick={() => {
  205. onNewViewsSaved?.([
  206. {
  207. ...suggestion,
  208. saveQueryToView: false,
  209. },
  210. ]);
  211. trackAnalytics(analyticsKey, {
  212. organization,
  213. persisted: false,
  214. label: suggestion.label,
  215. query: suggestion.query,
  216. });
  217. }}
  218. >
  219. {/*
  220. Saved search labels have an average length of approximately 16 characters
  221. This container fits 16 'a's comfortably, and 20 'a's before overflowing.
  222. */}
  223. <StyledOverflowEllipsisTextContainer>
  224. {suggestion.label}
  225. </StyledOverflowEllipsisTextContainer>
  226. <QueryWrapper>
  227. <FormattedQuery query={suggestion.query} />
  228. <ActionsWrapper className="data-actions-wrapper">
  229. <StyledButton
  230. size="zero"
  231. onClick={e => {
  232. e.stopPropagation();
  233. onNewViewsSaved?.([
  234. {
  235. ...suggestion,
  236. saveQueryToView: true,
  237. },
  238. ]);
  239. }}
  240. analyticsEventKey={analyticsKey}
  241. analyticsEventName={analyticsEventName}
  242. analyticsParams={{
  243. persisted: true,
  244. label: suggestion.label,
  245. query: suggestion.query,
  246. }}
  247. borderless
  248. >
  249. {t('Save as new view')}
  250. </StyledButton>
  251. </ActionsWrapper>
  252. </QueryWrapper>
  253. <StyledInteractionStateLayer />
  254. </Suggestion>
  255. ))}
  256. </SuggestionList>
  257. </Suggestions>
  258. );
  259. }
  260. export default AddViewPage;
  261. const Suggestions = styled('section')`
  262. width: 100%;
  263. `;
  264. const SavedSearchesTitle = styled('div')`
  265. align-items: center;
  266. display: flex;
  267. gap: ${space(1)};
  268. `;
  269. const StyledInteractionStateLayer = styled(InteractionStateLayer)`
  270. border-radius: 4px;
  271. width: 100.8%;
  272. `;
  273. const StyledOverflowEllipsisTextContainer = styled(OverflowEllipsisTextContainer)`
  274. width: 170px;
  275. `;
  276. const TitleWrapper = styled('div')`
  277. display: flex;
  278. justify-content: space-between;
  279. color: ${p => p.theme.subText};
  280. font-weight: 550;
  281. font-size: ${p => p.theme.fontSizeMedium};
  282. margin-bottom: ${space(0.75)};
  283. `;
  284. const ActionsWrapper = styled('div')`
  285. display: flex;
  286. align-items: center;
  287. gap: ${space(0.75)};
  288. visibility: hidden;
  289. `;
  290. const StyledButton = styled(Button)`
  291. font-size: ${p => p.theme.fontSizeMedium};
  292. color: ${p => p.theme.subText};
  293. font-weight: ${p => p.theme.fontWeightNormal};
  294. padding: ${space(0.5)};
  295. border: none;
  296. &:hover {
  297. color: ${p => p.theme.subText};
  298. }
  299. `;
  300. const QueryWrapper = styled('div')`
  301. display: flex;
  302. justify-content: space-between;
  303. align-items: center;
  304. width: 100%;
  305. overflow: hidden;
  306. `;
  307. const SuggestionList = styled('ul')`
  308. display: flex;
  309. flex-direction: column;
  310. padding: 0;
  311. li:has(+ li:hover) {
  312. border-bottom: 1px solid transparent;
  313. }
  314. li:hover {
  315. border-bottom: 1px solid transparent;
  316. }
  317. li:last-child {
  318. border-bottom: 1px solid transparent;
  319. }
  320. `;
  321. const Suggestion = styled('li')`
  322. position: relative;
  323. display: inline-grid;
  324. grid-template-columns: 170px auto;
  325. align-items: center;
  326. padding: ${space(1)} 0;
  327. border-bottom: 1px solid ${p => p.theme.innerBorder};
  328. gap: ${space(1)};
  329. &:hover {
  330. cursor: pointer;
  331. }
  332. &:hover .data-actions-wrapper {
  333. visibility: visible;
  334. }
  335. `;
  336. const Banner = styled('div')`
  337. position: relative;
  338. display: flex;
  339. flex-direction: column;
  340. margin-bottom: 0;
  341. padding: 12px;
  342. gap: ${space(0.5)};
  343. border: 1px solid ${p => p.theme.border};
  344. border-radius: ${p => p.theme.panelBorderRadius};
  345. background: linear-gradient(
  346. 269.35deg,
  347. ${p => p.theme.backgroundTertiary} 0.32%,
  348. rgba(245, 243, 247, 0) 99.69%
  349. );
  350. `;
  351. const Title = styled('div')`
  352. font-size: ${p => p.theme.fontSizeMedium};
  353. font-weight: ${p => p.theme.fontWeightBold};
  354. `;
  355. const BannerNoteBold = styled('div')`
  356. display: inline;
  357. font-weight: ${p => p.theme.fontWeightBold};
  358. `;
  359. const SubTitle = styled('div')`
  360. display: flex;
  361. flex-direction: column;
  362. font-weight: ${p => p.theme.fontWeightNormal};
  363. font-size: ${p => p.theme.fontSizeMedium};
  364. gap: ${space(0.5)};
  365. `;
  366. const AddViewWrapper = styled('div')`
  367. display: flex;
  368. flex-direction: column;
  369. gap: ${space(2)};
  370. @media (max-width: ${p => p.theme.breakpoints.small}) {
  371. flex-direction: column;
  372. align-items: center;
  373. }
  374. `;
  375. const BannerStar1 = styled('img')`
  376. position: absolute;
  377. bottom: 10px;
  378. right: 150px;
  379. transform: scale(0.9);
  380. @media (max-width: ${p => p.theme.breakpoints.large}) {
  381. display: none;
  382. }
  383. `;
  384. const BannerStar2 = styled('img')`
  385. position: absolute;
  386. top: 10px;
  387. right: 120px;
  388. transform: rotate(-30deg) scale(0.7);
  389. @media (max-width: ${p => p.theme.breakpoints.large}) {
  390. display: none;
  391. }
  392. `;
  393. const BannerStar3 = styled('img')`
  394. position: absolute;
  395. bottom: 30px;
  396. right: 80px;
  397. transform: rotate(80deg) scale(0.6);
  398. @media (max-width: ${p => p.theme.breakpoints.large}) {
  399. display: none;
  400. }
  401. `;
  402. const ConfirmModalMessage = styled('div')`
  403. display: flex;
  404. justify-content: center;
  405. font-weight: ${p => p.theme.fontWeightBold};
  406. `;
  407. const Container = styled('div')`
  408. display: inline-flex;
  409. flex-direction: column;
  410. align-items: flex-start;
  411. text-align: left;
  412. gap: ${space(1)};
  413. `;
  414. const AFewNotesList = styled('ul')`
  415. margin-bottom: ${space(0.5)};
  416. `;
  417. const FittedLinkButton = styled(LinkButton)`
  418. width: fit-content;
  419. `;
  420. const DismissButton = styled(Button)`
  421. position: absolute;
  422. top: ${space(1)};
  423. right: ${space(1)};
  424. color: ${p => p.theme.subText};
  425. `;