addViewPage.tsx 14 KB

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