import {createContext} from 'react'; import type {RouteComponentProps} from 'react-router'; import styled from '@emotion/styled'; import type {Location} from 'history'; import isEqual from 'lodash/isEqual'; import pick from 'lodash/pick'; import {Alert} from 'sentry/components/alert'; import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent'; import * as Layout from 'sentry/components/layouts/thirds'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import NoProjectMessage from 'sentry/components/noProjectMessage'; import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import PickProjectToContinue from 'sentry/components/pickProjectToContinue'; import {PAGE_URL_PARAM, URL_PARAM} from 'sentry/constants/pageFilters'; import {IconInfo} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type { Deploy, Organization, PageFilters, ReleaseMeta, ReleaseProject, ReleaseWithHealth, SessionApiResponse, } from 'sentry/types'; import {SessionFieldWithOperation} from 'sentry/types'; import {formatVersion} from 'sentry/utils/formatters'; import type {WithRouteAnalyticsProps} from 'sentry/utils/routeAnalytics/withRouteAnalytics'; import withRouteAnalytics from 'sentry/utils/routeAnalytics/withRouteAnalytics'; import routeTitleGen from 'sentry/utils/routeTitle'; import {getCount} from 'sentry/utils/sessions'; import withOrganization from 'sentry/utils/withOrganization'; import withPageFilters from 'sentry/utils/withPageFilters'; import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView'; import type {ReleaseBounds} from '../utils'; import {getReleaseBounds, searchReleaseVersion} from '../utils'; import ReleaseHeader from './header/releaseHeader'; type ReleaseContextType = { deploys: Deploy[]; hasHealthData: boolean; project: Required; refetchData: () => void; release: ReleaseWithHealth; releaseBounds: ReleaseBounds; releaseMeta: ReleaseMeta; }; const ReleaseContext = createContext({} as ReleaseContextType); type RouteParams = { orgId: string; release: string; }; type Props = RouteComponentProps & WithRouteAnalyticsProps & { children: React.ReactNode; organization: Organization; releaseMeta: ReleaseMeta; selection: PageFilters; }; type State = { deploys: Deploy[]; release: ReleaseWithHealth; sessions: SessionApiResponse | null; } & DeprecatedAsyncView['state']; class ReleasesDetail extends DeprecatedAsyncView { shouldReload = true; getTitle() { const {params, organization, selection} = this.props; const {release} = this.state; // The release details page will always have only one project selected const project = release?.projects.find(p => p.id === selection.projects[0]); return routeTitleGen( t('Release %s', formatVersion(params.release)), organization.slug, false, project?.slug ); } getDefaultState() { return { ...super.getDefaultState(), deploys: [], sessions: null, }; } componentDidUpdate(prevProps: Props, prevState: State) { const {organization, params, location} = this.props; if ( prevProps.params.release !== params.release || prevProps.organization.slug !== organization.slug || !isEqual( this.pickLocationQuery(prevProps.location), this.pickLocationQuery(location) ) ) { super.componentDidUpdate(prevProps, prevState); } } getEndpoints(): ReturnType { const {organization, location, params, releaseMeta} = this.props; const basePath = `/organizations/${organization.slug}/releases/${encodeURIComponent( params.release )}/`; const endpoints: ReturnType = [ [ 'release', basePath, { query: { adoptionStages: 1, ...normalizeDateTimeParams(this.pickLocationQuery(location)), }, }, ], ]; if (releaseMeta.deployCount > 0) { endpoints.push([ 'deploys', `${basePath}deploys/`, { query: { project: location.query.project, }, }, ]); } // Used to figure out if the release has any health data endpoints.push([ 'sessions', `/organizations/${organization.slug}/sessions/`, { query: { project: location.query.project, environment: location.query.environment ?? [], query: searchReleaseVersion(params.release), field: 'sum(session)', statsPeriod: '90d', interval: '1d', }, }, { allowError: error => error.status === 400, }, ]); return endpoints; } pickLocationQuery(location: Location) { return pick(location.query, [ ...Object.values(URL_PARAM), ...Object.values(PAGE_URL_PARAM), ]); } renderError(...args) { const possiblyWrongProject = Object.values(this.state.errors).find( e => e?.status === 404 || e?.status === 403 ); if (possiblyWrongProject) { return ( {t('This release may not be in your selected project.')} ); } return super.renderError(...args); } renderLoading() { return ( ); } renderBody() { const {organization, location, selection, releaseMeta} = this.props; const {release, deploys, sessions, reloading} = this.state; const project = release?.projects.find(p => p.id === selection.projects[0]); const releaseBounds = getReleaseBounds(release); if (!project || !release) { if (reloading) { return ; } return null; } return ( 0, releaseBounds, }} > {this.props.children} ); } } // ======================================================================== // RELEASE DETAIL CONTAINER // ======================================================================== type ReleasesDetailContainerProps = Omit; type ReleasesDetailContainerState = { releaseMeta: ReleaseMeta | null; } & DeprecatedAsyncComponent['state']; class ReleasesDetailContainer extends DeprecatedAsyncComponent< ReleasesDetailContainerProps, ReleasesDetailContainerState > { shouldReload = true; getEndpoints(): ReturnType { const {organization, params} = this.props; // fetch projects this release belongs to return [ [ 'releaseMeta', `/organizations/${organization.slug}/releases/${encodeURIComponent( params.release )}/meta/`, ], ]; } componentDidMount() { super.componentDidMount(); this.removeGlobalDateTimeFromUrl(); this.props.setRouteAnalyticsParams({release: this.props.params.release}); } componentDidUpdate( prevProps: ReleasesDetailContainerProps, prevState: ReleasesDetailContainerState ) { const {organization, params} = this.props; this.removeGlobalDateTimeFromUrl(); if ( prevProps.params.release !== params.release || prevProps.organization.slug !== organization.slug ) { this.props.setRouteAnalyticsParams({release: this.props.params.release}); super.componentDidUpdate(prevProps, prevState); } } removeGlobalDateTimeFromUrl() { const {router, location} = this.props; const {start, end, statsPeriod, utc, ...restQuery} = location.query; if (start || end || statsPeriod || utc) { router.replace({ ...location, query: restQuery, }); } } renderError(...args) { const has404Errors = Object.values(this.state.errors).find(e => e?.status === 404); if (has404Errors) { // This catches a 404 coming from the release endpoint and displays a custom error message. return ( {t('This release could not be found.')} ); } return super.renderError(...args); } isProjectMissingInUrl() { const projectId = this.props.location.query.project; return !projectId || typeof projectId !== 'string'; } renderLoading() { return ( ); } renderProjectsFooterMessage() { return ( {t('Only projects with this release are visible.')} ); } renderBody() { const {organization, params, router} = this.props; const {releaseMeta} = this.state; if (!releaseMeta) { return null; } const {projects} = releaseMeta; if (this.isProjectMissingInUrl()) { return ( ({ id: String(id), slug, }))} router={router} nextPath={{ pathname: `/organizations/${organization.slug}/releases/${encodeURIComponent( params.release )}/`, }} noProjectRedirectPath={`/organizations/${organization.slug}/releases/`} /> ); } return ( p.slug)} > ); } } const ProjectsFooterMessage = styled('div')` display: grid; align-items: center; grid-template-columns: min-content 1fr; gap: ${space(1)}; `; export {ReleaseContext, ReleasesDetailContainer}; export default withRouteAnalytics( withPageFilters(withOrganization(ReleasesDetailContainer)) );