import {ComponentType, Fragment} from 'react'; import {css, Theme, withTheme} from '@emotion/react'; import styled from '@emotion/styled'; import isEqual from 'lodash/isEqual'; import round from 'lodash/round'; import moment from 'moment'; import AsyncComponent from 'sentry/components/asyncComponent'; import {Button} from 'sentry/components/button'; import {BarChart} from 'sentry/components/charts/barChart'; import MarkLine from 'sentry/components/charts/components/markLine'; import {DateTimeObject} from 'sentry/components/charts/utils'; import Link from 'sentry/components/links/link'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import PanelTable from 'sentry/components/panels/panelTable'; import Placeholder from 'sentry/components/placeholder'; import {IconArrow} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {Organization, Project} from 'sentry/types'; import {ColorOrAlias} from 'sentry/utils/theme'; import toArray from 'sentry/utils/toArray'; import {ProjectBadge, ProjectBadgeContainer} from './styles'; import {barAxisLabel, groupByTrend, sortSeriesByDay} from './utils'; type Props = AsyncComponent['props'] & { organization: Organization; projects: Project[]; teamSlug: string; theme: Theme; } & DateTimeObject; type ProjectReleaseCount = { last_week_totals: Record; project_avgs: Record; release_counts: Record; }; type State = AsyncComponent['state'] & { /** weekly selected date range */ periodReleases: ProjectReleaseCount | null; /** Locked to last 7 days */ weekReleases: ProjectReleaseCount | null; }; class TeamReleases extends AsyncComponent { shouldRenderBadRequests = true; getDefaultState(): State { return { ...super.getDefaultState(), weekReleases: null, periodReleases: null, }; } getEndpoints() { const {organization, start, end, period, utc, teamSlug} = this.props; const datetime = {start, end, period, utc}; const endpoints: ReturnType = [ [ 'periodReleases', `/teams/${organization.slug}/${teamSlug}/release-count/`, { query: { ...normalizeDateTimeParams(datetime), }, }, ], [ 'weekReleases', `/teams/${organization.slug}/${teamSlug}/release-count/`, { query: { statsPeriod: '7d', }, }, ], ]; return endpoints; } componentDidUpdate(prevProps: Props) { const {teamSlug, start, end, period, utc} = this.props; if ( prevProps.start !== start || prevProps.end !== end || prevProps.period !== period || prevProps.utc !== utc || !isEqual(prevProps.teamSlug, teamSlug) ) { this.remountComponent(); } } getReleaseCount(projectId: number, dataset: 'week' | 'period'): number | null { const {periodReleases, weekReleases} = this.state; const releasesPeriod = dataset === 'week' ? weekReleases?.last_week_totals : periodReleases?.project_avgs; const count = releasesPeriod?.[projectId] ? Math.ceil(releasesPeriod?.[projectId]) : 0; return count; } getTrend(projectId: number): number | null { const periodCount = this.getReleaseCount(projectId, 'period'); const weekCount = this.getReleaseCount(projectId, 'week'); if (periodCount === null || weekCount === null) { return null; } return weekCount - periodCount; } renderLoading() { return this.renderBody(); } renderReleaseCount(projectId: string, dataset: 'week' | 'period') { const {loading} = this.state; if (loading) { return (
); } const count = this.getReleaseCount(Number(projectId), dataset); if (count === null) { return '\u2014'; } return count; } renderTrend(projectId: string) { const {loading} = this.state; if (loading) { return (
); } const trend = this.getTrend(Number(projectId)); if (trend === null) { return '\u2014'; } return ( = 0 ? 'successText' : 'errorText'}> {`${round(Math.abs(trend), 3)}`} = 0 ? 'up' : 'down'} size="xs" /> ); } renderBody() { const {projects, period, theme, organization} = this.props; const {periodReleases} = this.state; const sortedProjects = projects .map(project => ({project, trend: this.getTrend(Number(project.id)) ?? 0})) .sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend)); const groupedProjects = groupByTrend(sortedProjects); const data = Object.entries(periodReleases?.release_counts ?? {}).map( ([bucket, count]) => ({ value: Math.ceil(count), name: new Date(bucket).getTime(), }) ); const seriesData = sortSeriesByDay(data); const averageValues = Object.values(periodReleases?.project_avgs ?? {}); const projectAvgSum = averageValues.reduce( (total, currentData) => total + currentData, 0 ); const totalPeriodAverage = Math.ceil(projectAvgSum / averageValues.length); return (
{ // `seriesParams` can be an array or an object :/ const [series] = toArray(seriesParams); const dateFormat = 'MMM D'; const startDate = moment(series.data[0]).format(dateFormat); const endDate = moment(series.data[0]).add(7, 'days').format(dateFormat); return [ '
', `
${series.marker} ${series.seriesName} ${series.data[1]}
`, `
Last ${period} Average ${totalPeriodAverage}
`, '
', ``, '
', ].join(''); }, }} />
{t('Learn More')} } headers={[ t('Releases Per Project'), {tct('Last [period] Average', {period})} , {t('Last 7 Days')}, {t('Difference')}, ]} > {groupedProjects.map(({project}) => ( {this.renderReleaseCount(project.id, 'period')} {this.renderReleaseCount(project.id, 'week')} {this.renderTrend(project.id)} ))}
); } } export default withTheme(TeamReleases as ComponentType); const ChartWrapper = styled('div')` padding: ${space(2)} ${space(2)} 0 ${space(2)}; border-bottom: 1px solid ${p => p.theme.border}; `; const StyledPanelTable = styled(PanelTable)<{isEmpty: boolean}>` grid-template-columns: 1fr 0.2fr 0.2fr 0.2fr; white-space: nowrap; margin-bottom: 0; border: 0; font-size: ${p => p.theme.fontSizeMedium}; box-shadow: unset; & > div { padding: ${space(1)} ${space(2)}; } ${p => p.isEmpty && css` & > div:last-child { padding: 48px ${space(2)}; } `} `; const RightAligned = styled('span')` text-align: right; `; const ScoreWrapper = styled('div')` display: flex; align-items: center; justify-content: flex-end; text-align: right; `; const PaddedIconArrow = styled(IconArrow)` margin: 0 ${space(0.5)}; `; const SubText = styled('div')<{color: ColorOrAlias}>` color: ${p => p.theme[p.color]}; `;