index.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. import {Fragment} from 'react';
  2. import {forceCheck} from 'react-lazyload';
  3. import styled from '@emotion/styled';
  4. import pick from 'lodash/pick';
  5. import {fetchTagValues} from 'sentry/actionCreators/tags';
  6. import type {Client} from 'sentry/api';
  7. import {Alert} from 'sentry/components/alert';
  8. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  9. import EmptyMessage from 'sentry/components/emptyMessage';
  10. import FloatingFeedbackWidget from 'sentry/components/feedback/widget/floatingFeedbackWidget';
  11. import * as Layout from 'sentry/components/layouts/thirds';
  12. import ExternalLink from 'sentry/components/links/externalLink';
  13. import LoadingIndicator from 'sentry/components/loadingIndicator';
  14. import NoProjectMessage from 'sentry/components/noProjectMessage';
  15. import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
  16. import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
  17. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  18. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  19. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  20. import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
  21. import Pagination from 'sentry/components/pagination';
  22. import Panel from 'sentry/components/panels/panel';
  23. import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder';
  24. import {getRelativeSummary} from 'sentry/components/timeRangeSelector/utils';
  25. import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
  26. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  27. import {releaseHealth} from 'sentry/data/platformCategories';
  28. import {IconSearch} from 'sentry/icons';
  29. import {t} from 'sentry/locale';
  30. import ProjectsStore from 'sentry/stores/projectsStore';
  31. import {space} from 'sentry/styles/space';
  32. import type {PageFilters} from 'sentry/types/core';
  33. import type {Tag} from 'sentry/types/group';
  34. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  35. import type {Organization} from 'sentry/types/organization';
  36. import type {AvatarProject, Project} from 'sentry/types/project';
  37. import type {Release} from 'sentry/types/release';
  38. import {ReleaseStatus} from 'sentry/types/release';
  39. import {trackAnalytics} from 'sentry/utils/analytics';
  40. import {SEMVER_TAGS} from 'sentry/utils/discover/fields';
  41. import Projects from 'sentry/utils/projects';
  42. import routeTitleGen from 'sentry/utils/routeTitle';
  43. import withApi from 'sentry/utils/withApi';
  44. import withOrganization from 'sentry/utils/withOrganization';
  45. import withPageFilters from 'sentry/utils/withPageFilters';
  46. import withProjects from 'sentry/utils/withProjects';
  47. import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
  48. import Header from '../components/header';
  49. import ReleaseArchivedNotice from '../detail/overview/releaseArchivedNotice';
  50. import {isMobileRelease} from '../utils';
  51. import ReleaseCard from './releaseCard';
  52. import ReleasesAdoptionChart from './releasesAdoptionChart';
  53. import ReleasesDisplayOptions, {ReleasesDisplayOption} from './releasesDisplayOptions';
  54. import ReleasesPromo from './releasesPromo';
  55. import ReleasesRequest from './releasesRequest';
  56. import ReleasesSortOptions, {ReleasesSortOption} from './releasesSortOptions';
  57. import ReleasesStatusOptions, {ReleasesStatusOption} from './releasesStatusOptions';
  58. type RouteParams = {
  59. orgId: string;
  60. };
  61. type Props = RouteComponentProps<RouteParams, {}> & {
  62. api: Client;
  63. organization: Organization;
  64. projects: Project[];
  65. selection: PageFilters;
  66. };
  67. type State = {
  68. releases: Release[];
  69. } & DeprecatedAsyncView['state'];
  70. class ReleasesList extends DeprecatedAsyncView<Props, State> {
  71. shouldReload = true;
  72. shouldRenderBadRequests = true;
  73. filterKeys = [
  74. ...Object.values(SEMVER_TAGS),
  75. {
  76. key: 'release',
  77. name: 'release',
  78. },
  79. ].reduce((acc, tag) => {
  80. acc[tag.key] = tag;
  81. return acc;
  82. }, {});
  83. getTitle() {
  84. return routeTitleGen(t('Releases'), this.props.organization.slug, false);
  85. }
  86. getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
  87. const {organization, location} = this.props;
  88. const {statsPeriod} = location.query;
  89. const activeSort = this.getSort();
  90. const activeStatus = this.getStatus();
  91. const query = {
  92. ...pick(location.query, ['project', 'environment', 'cursor', 'query', 'sort']),
  93. summaryStatsPeriod: statsPeriod,
  94. per_page: 20,
  95. flatten: activeSort === ReleasesSortOption.DATE ? 0 : 1,
  96. adoptionStages: 1,
  97. status:
  98. activeStatus === ReleasesStatusOption.ARCHIVED
  99. ? ReleaseStatus.ARCHIVED
  100. : ReleaseStatus.ACTIVE,
  101. };
  102. const endpoints: ReturnType<DeprecatedAsyncView['getEndpoints']> = [
  103. [
  104. 'releases', // stateKey
  105. `/organizations/${organization.slug}/releases/`, // endpoint
  106. {query}, // params
  107. {disableEntireQuery: true}, // options - prevent cursor from being passed into query
  108. ],
  109. ];
  110. return endpoints;
  111. }
  112. componentDidUpdate(prevProps: Props, prevState: State) {
  113. super.componentDidUpdate(prevProps, prevState);
  114. if (prevState.releases !== this.state.releases) {
  115. /**
  116. * Manually trigger checking for elements in viewport.
  117. * Helpful when LazyLoad components enter the viewport without resize or scroll events,
  118. * https://github.com/twobin/react-lazyload#forcecheck
  119. *
  120. * HealthStatsCharts are being rendered only when they are scrolled into viewport.
  121. * This is how we re-check them without scrolling once releases change as this view
  122. * uses shouldReload=true and there is no reloading happening.
  123. */
  124. forceCheck();
  125. }
  126. }
  127. getQuery() {
  128. const {query} = this.props.location.query;
  129. return typeof query === 'string' ? query : undefined;
  130. }
  131. getSort(): ReleasesSortOption {
  132. const {environments} = this.props.selection;
  133. const {sort} = this.props.location.query;
  134. // Require 1 environment for date adopted
  135. if (sort === ReleasesSortOption.ADOPTION && environments.length !== 1) {
  136. return ReleasesSortOption.DATE;
  137. }
  138. const sortExists = Object.values(ReleasesSortOption).includes(sort);
  139. if (sortExists) {
  140. return sort;
  141. }
  142. return ReleasesSortOption.DATE;
  143. }
  144. getDisplay(): ReleasesDisplayOption {
  145. const {display} = this.props.location.query;
  146. switch (display) {
  147. case ReleasesDisplayOption.USERS:
  148. return ReleasesDisplayOption.USERS;
  149. default:
  150. return ReleasesDisplayOption.SESSIONS;
  151. }
  152. }
  153. getStatus(): ReleasesStatusOption {
  154. const {status} = this.props.location.query;
  155. switch (status) {
  156. case ReleasesStatusOption.ARCHIVED:
  157. return ReleasesStatusOption.ARCHIVED;
  158. default:
  159. return ReleasesStatusOption.ACTIVE;
  160. }
  161. }
  162. getSelectedProject(): Project | undefined {
  163. const {selection, projects} = this.props;
  164. const selectedProjectId =
  165. selection.projects && selection.projects.length === 1 && selection.projects[0];
  166. return projects?.find(p => p.id === `${selectedProjectId}`);
  167. }
  168. getSelectedProjectSlugs(): string[] {
  169. const {selection, projects} = this.props;
  170. const projIdSet = new Set(selection.projects);
  171. return projects.reduce((result: string[], proj) => {
  172. if (projIdSet.has(Number(proj.id))) {
  173. result.push(proj.slug);
  174. }
  175. return result;
  176. }, []);
  177. }
  178. get projectHasSessions() {
  179. return this.getSelectedProject()?.hasSessions ?? null;
  180. }
  181. handleSearch = (query: string) => {
  182. const {location, router} = this.props;
  183. router.push({
  184. ...location,
  185. query: {...location.query, cursor: undefined, query},
  186. });
  187. };
  188. handleSortBy = (sort: string) => {
  189. const {location, router} = this.props;
  190. router.push({
  191. ...location,
  192. query: {...location.query, cursor: undefined, sort},
  193. });
  194. };
  195. handleDisplay = (display: string) => {
  196. const {location, router} = this.props;
  197. let sort = location.query.sort;
  198. if (
  199. sort === ReleasesSortOption.USERS_24_HOURS &&
  200. display === ReleasesDisplayOption.SESSIONS
  201. ) {
  202. sort = ReleasesSortOption.SESSIONS_24_HOURS;
  203. } else if (
  204. sort === ReleasesSortOption.SESSIONS_24_HOURS &&
  205. display === ReleasesDisplayOption.USERS
  206. ) {
  207. sort = ReleasesSortOption.USERS_24_HOURS;
  208. } else if (
  209. sort === ReleasesSortOption.CRASH_FREE_USERS &&
  210. display === ReleasesDisplayOption.SESSIONS
  211. ) {
  212. sort = ReleasesSortOption.CRASH_FREE_SESSIONS;
  213. } else if (
  214. sort === ReleasesSortOption.CRASH_FREE_SESSIONS &&
  215. display === ReleasesDisplayOption.USERS
  216. ) {
  217. sort = ReleasesSortOption.CRASH_FREE_USERS;
  218. }
  219. router.push({
  220. ...location,
  221. query: {...location.query, cursor: undefined, display, sort},
  222. });
  223. };
  224. handleStatus = (status: string) => {
  225. const {location, router} = this.props;
  226. router.push({
  227. ...location,
  228. query: {...location.query, cursor: undefined, status},
  229. });
  230. };
  231. trackAddReleaseHealth = () => {
  232. const {organization, selection} = this.props;
  233. if (organization.id && selection.projects[0]) {
  234. trackAnalytics('releases_list.click_add_release_health', {
  235. organization,
  236. project_id: selection.projects[0],
  237. });
  238. }
  239. };
  240. tagValueLoader = (key: string, search: string) => {
  241. const {location, organization} = this.props;
  242. const {project: projectId} = location.query;
  243. return fetchTagValues({
  244. api: this.api,
  245. orgSlug: organization.slug,
  246. tagKey: key,
  247. search,
  248. projectIds: projectId ? [projectId] : undefined,
  249. endpointParams: normalizeDateTimeParams(location.query),
  250. });
  251. };
  252. getTagValues = async (tag: Tag, currentQuery: string): Promise<string[]> => {
  253. const values = await this.tagValueLoader(tag.key, currentQuery);
  254. return values.map(({value}) => value);
  255. };
  256. shouldShowLoadingIndicator() {
  257. const {loading, releases, reloading} = this.state;
  258. return (loading && !reloading) || (loading && !releases?.length);
  259. }
  260. renderLoading() {
  261. return this.renderBody();
  262. }
  263. renderError() {
  264. return this.renderBody();
  265. }
  266. get shouldShowQuickstart() {
  267. const {releases} = this.state;
  268. const selectedProject = this.getSelectedProject();
  269. const hasReleasesSetup = selectedProject?.features.includes('releases');
  270. return !releases?.length && !hasReleasesSetup && selectedProject;
  271. }
  272. renderEmptyMessage() {
  273. const {location} = this.props;
  274. const {statsPeriod, start, end} = location.query;
  275. const searchQuery = this.getQuery();
  276. const activeSort = this.getSort();
  277. const activeStatus = this.getStatus();
  278. const selectedPeriod =
  279. !!start && !!end
  280. ? t('time range')
  281. : getRelativeSummary(statsPeriod || DEFAULT_STATS_PERIOD).toLowerCase();
  282. if (searchQuery?.length) {
  283. return (
  284. <Panel>
  285. <EmptyMessage icon={<IconSearch size="xl" />} size="large">{`${t(
  286. 'There are no releases that match'
  287. )}: '${searchQuery}'.`}</EmptyMessage>
  288. </Panel>
  289. );
  290. }
  291. if (activeSort === ReleasesSortOption.USERS_24_HOURS) {
  292. return (
  293. <Panel>
  294. <EmptyMessage icon={<IconSearch size="xl" />} size="large">
  295. {t(
  296. 'There are no releases with active user data (users in the last 24 hours).'
  297. )}
  298. </EmptyMessage>
  299. </Panel>
  300. );
  301. }
  302. if (activeSort === ReleasesSortOption.SESSIONS_24_HOURS) {
  303. return (
  304. <Panel>
  305. <EmptyMessage icon={<IconSearch size="xl" />} size="large">
  306. {t(
  307. 'There are no releases with active session data (sessions in the last 24 hours).'
  308. )}
  309. </EmptyMessage>
  310. </Panel>
  311. );
  312. }
  313. if (
  314. activeSort === ReleasesSortOption.BUILD ||
  315. activeSort === ReleasesSortOption.SEMVER
  316. ) {
  317. return (
  318. <Panel>
  319. <EmptyMessage icon={<IconSearch size="xl" />} size="large">
  320. {t('There are no releases with semantic versioning.')}
  321. </EmptyMessage>
  322. </Panel>
  323. );
  324. }
  325. if (activeSort !== ReleasesSortOption.DATE) {
  326. return (
  327. <Panel>
  328. <EmptyMessage icon={<IconSearch size="xl" />} size="large">
  329. {`${t('There are no releases with data in the')} ${selectedPeriod}.`}
  330. </EmptyMessage>
  331. </Panel>
  332. );
  333. }
  334. if (activeStatus === ReleasesStatusOption.ARCHIVED) {
  335. return (
  336. <Panel>
  337. <EmptyMessage icon={<IconSearch size="xl" />} size="large">
  338. {t('There are no archived releases.')}
  339. </EmptyMessage>
  340. </Panel>
  341. );
  342. }
  343. return (
  344. <Panel>
  345. <EmptyMessage icon={<IconSearch size="xl" />} size="large">
  346. {`${t('There are no releases with data in the')} ${selectedPeriod}.`}
  347. </EmptyMessage>
  348. </Panel>
  349. );
  350. }
  351. renderHealthCta() {
  352. const {organization} = this.props;
  353. const {releases} = this.state;
  354. const selectedProject = this.getSelectedProject();
  355. if (!selectedProject || this.projectHasSessions !== false || !releases?.length) {
  356. return null;
  357. }
  358. return (
  359. <Projects orgId={organization.slug} slugs={[selectedProject.slug]}>
  360. {({projects, initiallyLoaded, fetchError}) => {
  361. const project: AvatarProject | undefined =
  362. projects?.length === 1 ? projects.at(0) : undefined;
  363. const projectCanHaveReleases =
  364. project?.platform && releaseHealth.includes(project.platform);
  365. if (!initiallyLoaded || fetchError || !projectCanHaveReleases) {
  366. return null;
  367. }
  368. return (
  369. <Alert type="info" showIcon>
  370. <AlertText>
  371. <div>
  372. {t(
  373. 'To track user adoption, crash rates, session data and more, add Release Health to your current setup.'
  374. )}
  375. </div>
  376. <ExternalLink
  377. href="https://docs.sentry.io/product/releases/setup/#release-health"
  378. onClick={this.trackAddReleaseHealth}
  379. >
  380. {t('Add Release Health')}
  381. </ExternalLink>
  382. </AlertText>
  383. </Alert>
  384. );
  385. }}
  386. </Projects>
  387. );
  388. }
  389. renderInnerBody(
  390. activeDisplay: ReleasesDisplayOption,
  391. showReleaseAdoptionStages: boolean
  392. ) {
  393. const {location, selection, organization, router} = this.props;
  394. const {releases, reloading, releasesPageLinks} = this.state;
  395. const selectedProject = this.getSelectedProject();
  396. const hasReleasesSetup = selectedProject?.features.includes('releases');
  397. if (this.shouldShowLoadingIndicator()) {
  398. return <LoadingIndicator />;
  399. }
  400. if (!releases?.length && hasReleasesSetup) {
  401. return this.renderEmptyMessage();
  402. }
  403. if (this.shouldShowQuickstart) {
  404. return <ReleasesPromo organization={organization} project={selectedProject!} />;
  405. }
  406. return (
  407. <ReleasesRequest
  408. releases={releases.map(({version}) => version)}
  409. organization={organization}
  410. selection={selection}
  411. location={location}
  412. display={[this.getDisplay()]}
  413. releasesReloading={reloading}
  414. healthStatsPeriod={location.query.healthStatsPeriod}
  415. >
  416. {({isHealthLoading, getHealthData}) => {
  417. const singleProjectSelected =
  418. selection.projects?.length === 1 &&
  419. selection.projects[0] !== ALL_ACCESS_PROJECTS;
  420. // TODO: project specific chart should live on the project details page.
  421. const isMobileProject =
  422. selectedProject?.platform && isMobileRelease(selectedProject.platform);
  423. return (
  424. <Fragment>
  425. {singleProjectSelected && this.projectHasSessions && isMobileProject && (
  426. <ReleasesAdoptionChart
  427. organization={organization}
  428. selection={selection}
  429. location={location}
  430. router={router}
  431. activeDisplay={activeDisplay}
  432. />
  433. )}
  434. {releases.map((release, index) => (
  435. <ReleaseCard
  436. key={`${release.projects[0].slug}-${release.version}`}
  437. activeDisplay={activeDisplay}
  438. release={release}
  439. organization={organization}
  440. location={location}
  441. selection={selection}
  442. reloading={reloading}
  443. showHealthPlaceholders={isHealthLoading}
  444. isTopRelease={index === 0}
  445. getHealthData={getHealthData}
  446. showReleaseAdoptionStages={showReleaseAdoptionStages}
  447. />
  448. ))}
  449. <Pagination pageLinks={releasesPageLinks} />
  450. </Fragment>
  451. );
  452. }}
  453. </ReleasesRequest>
  454. );
  455. }
  456. renderBody() {
  457. const {organization, selection} = this.props;
  458. const {releases, reloading, error} = this.state;
  459. const activeSort = this.getSort();
  460. const activeStatus = this.getStatus();
  461. const activeDisplay = this.getDisplay();
  462. const hasAnyMobileProject = selection.projects
  463. .map(id => `${id}`)
  464. .map(ProjectsStore.getById)
  465. .some(project => project?.platform && isMobileRelease(project.platform));
  466. const showReleaseAdoptionStages =
  467. hasAnyMobileProject && selection.environments.length === 1;
  468. return (
  469. <PageFiltersContainer showAbsolute={false}>
  470. <NoProjectMessage organization={organization}>
  471. <Header />
  472. <Layout.Body>
  473. <Layout.Main fullWidth>
  474. {this.renderHealthCta()}
  475. <ReleasesPageFilterBar condensed>
  476. <GuideAnchor target="release_projects">
  477. <ProjectPageFilter />
  478. </GuideAnchor>
  479. <EnvironmentPageFilter />
  480. <DatePageFilter
  481. disallowArbitraryRelativeRanges
  482. menuFooterMessage={t(
  483. 'Changing this date range will recalculate the release metrics.'
  484. )}
  485. />
  486. </ReleasesPageFilterBar>
  487. {this.shouldShowQuickstart ? null : (
  488. <SortAndFilterWrapper>
  489. <StyledSearchQueryBuilder
  490. onSearch={this.handleSearch}
  491. initialQuery={this.getQuery() || ''}
  492. filterKeys={this.filterKeys}
  493. getTagValues={this.getTagValues}
  494. placeholder={t('Search by version, build, package, or stage')}
  495. searchSource="releases"
  496. showUnsubmittedIndicator
  497. />
  498. <ReleasesStatusOptions
  499. selected={activeStatus}
  500. onSelect={this.handleStatus}
  501. />
  502. <ReleasesSortOptions
  503. selected={activeSort}
  504. selectedDisplay={activeDisplay}
  505. onSelect={this.handleSortBy}
  506. environments={selection.environments}
  507. />
  508. <ReleasesDisplayOptions
  509. selected={activeDisplay}
  510. onSelect={this.handleDisplay}
  511. />
  512. </SortAndFilterWrapper>
  513. )}
  514. {!reloading &&
  515. activeStatus === ReleasesStatusOption.ARCHIVED &&
  516. !!releases?.length && <ReleaseArchivedNotice multi />}
  517. {error
  518. ? super.renderError()
  519. : this.renderInnerBody(activeDisplay, showReleaseAdoptionStages)}
  520. <FloatingFeedbackWidget />
  521. </Layout.Main>
  522. </Layout.Body>
  523. </NoProjectMessage>
  524. </PageFiltersContainer>
  525. );
  526. }
  527. }
  528. const AlertText = styled('div')`
  529. display: flex;
  530. align-items: flex-start;
  531. justify-content: flex-start;
  532. gap: ${space(2)};
  533. > *:nth-child(1) {
  534. flex: 1;
  535. }
  536. flex-direction: column;
  537. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  538. flex-direction: row;
  539. }
  540. `;
  541. const ReleasesPageFilterBar = styled(PageFilterBar)`
  542. margin-bottom: ${space(2)};
  543. `;
  544. const SortAndFilterWrapper = styled('div')`
  545. display: grid;
  546. grid-template-columns: 1fr repeat(3, max-content);
  547. gap: ${space(2)};
  548. margin-bottom: ${space(2)};
  549. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  550. grid-template-columns: repeat(3, 1fr);
  551. & > div {
  552. width: auto;
  553. }
  554. }
  555. @media (max-width: ${p => p.theme.breakpoints.small}) {
  556. grid-template-columns: minmax(0, 1fr);
  557. }
  558. `;
  559. const StyledSearchQueryBuilder = styled(SearchQueryBuilder)`
  560. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  561. grid-column: 1 / -1;
  562. }
  563. `;
  564. export default withApi(withProjects(withOrganization(withPageFilters(ReleasesList))));