123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- import {Fragment} from 'react';
- import {css} from '@emotion/react';
- import styled from '@emotion/styled';
- import moment from 'moment';
- import {BarChart} from 'sentry/components/charts/barChart';
- import Count from 'sentry/components/count';
- import ProjectBadge from 'sentry/components/idBadge/projectBadge';
- import Link from 'sentry/components/links/link';
- import LoadingError from 'sentry/components/loadingError';
- import {PanelTable} from 'sentry/components/panels/panelTable';
- import Placeholder from 'sentry/components/placeholder';
- import TimeSince from 'sentry/components/timeSince';
- import {IconArrow} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {Group, Organization} from 'sentry/types';
- import {getTitle} from 'sentry/utils/events';
- import {useApiQuery} from 'sentry/utils/queryClient';
- interface TeamIssuesAgeProps {
- organization: Organization;
- teamSlug: string;
- }
- /**
- * takes "< 1 hour" and returns a datetime of 1 hour ago
- */
- function parseBucket(bucket: string): number {
- if (bucket === '> 1 year') {
- return moment().subtract(1, 'y').subtract(1, 'd').valueOf();
- }
- const [_, num, unit] = bucket.split(' ');
- return moment()
- .subtract(num, unit as any)
- .valueOf();
- }
- const bucketLabels = {
- '< 1 hour': t('1 hour'),
- '< 4 hour': t('4 hours'),
- '< 12 hour': t('12 hours'),
- '< 1 day': t('1 day'),
- '< 1 week': t('1 week'),
- '< 4 week': t('1 month'),
- '< 24 week': t('6 months'),
- '< 1 year': t('1 year'),
- '> 1 year': t('> 1 year'),
- };
- function TeamIssuesAge({organization, teamSlug}: TeamIssuesAgeProps) {
- const {
- data: oldestIssues,
- isLoading: isOldestIssuesLoading,
- isError: isOldestIssuesError,
- refetch: refetchOldestIssues,
- } = useApiQuery<Group[]>(
- [
- `/teams/${organization.slug}/${teamSlug}/issues/old/`,
- {
- query: {
- limit: 7,
- },
- },
- ],
- {staleTime: 5000}
- );
- const {
- data: unresolvedIssueAge,
- isLoading: isUnresolvedIssueAgeLoading,
- isError: isUnresolvedIssueAgeError,
- refetch: refetchUnresolvedIssueAge,
- } = useApiQuery<Record<string, number>>(
- [`/teams/${organization.slug}/${teamSlug}/unresolved-issue-age/`],
- {staleTime: 5000}
- );
- const isLoading = isOldestIssuesLoading || isUnresolvedIssueAgeLoading;
- if (isOldestIssuesError || isUnresolvedIssueAgeError) {
- return (
- <LoadingError
- onRetry={() => {
- refetchOldestIssues();
- refetchUnresolvedIssueAge();
- }}
- />
- );
- }
- const seriesData = Object.entries(unresolvedIssueAge ?? {})
- .map(([bucket, value]) => ({
- name: bucket,
- value,
- }))
- .sort((a, b) => parseBucket(b.name) - parseBucket(a.name));
- return (
- <div>
- <ChartWrapper>
- {isLoading && <Placeholder height="200px" />}
- {!isLoading && (
- <BarChart
- style={{height: 190}}
- legend={{right: 3, top: 0}}
- yAxis={{minInterval: 1}}
- xAxis={{
- type: 'category',
- min: 0,
- axisLabel: {
- showMaxLabel: true,
- showMinLabel: true,
- formatter: (bucket: string) => {
- return bucketLabels[bucket] ?? bucket;
- },
- },
- }}
- series={[
- {
- seriesName: t('Unresolved Issues'),
- silent: true,
- data: seriesData,
- barCategoryGap: '5%',
- },
- ]}
- />
- )}
- </ChartWrapper>
- <StyledPanelTable
- isEmpty={!oldestIssues || oldestIssues?.length === 0}
- emptyMessage={t('No unresolved issues for this team’s projects')}
- headers={[
- t('Oldest Issues'),
- <RightAligned key="events">{t('Events')}</RightAligned>,
- <RightAligned key="users">{t('Users')}</RightAligned>,
- <RightAligned key="age">
- {t('Age')} <IconArrow direction="down" size="xs" color="gray300" />
- </RightAligned>,
- ]}
- isLoading={isLoading}
- >
- {oldestIssues?.map(issue => {
- const {title} = getTitle(issue, organization?.features, false);
- return (
- <Fragment key={issue.id}>
- <ProjectTitleContainer>
- <ShadowlessProjectBadge
- disableLink
- hideName
- avatarSize={18}
- project={issue.project}
- />
- <TitleOverflow>
- <Link
- to={{
- pathname: `/organizations/${organization.slug}/issues/${issue.id}/`,
- }}
- >
- {title}
- </Link>
- </TitleOverflow>
- </ProjectTitleContainer>
- <RightAligned>
- <Count value={issue.count} />
- </RightAligned>
- <RightAligned>
- <Count value={issue.userCount} />
- </RightAligned>
- <RightAligned>
- <TimeSince date={issue.firstSeen} />
- </RightAligned>
- </Fragment>
- );
- })}
- </StyledPanelTable>
- </div>
- );
- }
- export default TeamIssuesAge;
- const ChartWrapper = styled('div')`
- padding: ${space(2)} ${space(2)} 0 ${space(2)};
- border-bottom: 1px solid ${p => p.theme.border};
- `;
- const StyledPanelTable = styled(PanelTable)`
- grid-template-columns: 1fr 0.15fr 0.15fr 0.25fr;
- white-space: nowrap;
- margin-bottom: 0;
- border: 0;
- font-size: ${p => p.theme.fontSizeMedium};
- box-shadow: unset;
- > * {
- padding: ${space(1)} ${space(2)};
- }
- ${p =>
- p.isEmpty &&
- css`
- & > div:last-child {
- padding: 48px ${space(2)};
- }
- `}
- `;
- const RightAligned = styled('span')`
- display: flex;
- align-items: center;
- justify-content: flex-end;
- `;
- const ProjectTitleContainer = styled('div')`
- ${p => p.theme.overflowEllipsis};
- display: flex;
- align-items: center;
- `;
- const TitleOverflow = styled('div')`
- ${p => p.theme.overflowEllipsis};
- `;
- const ShadowlessProjectBadge = styled(ProjectBadge)`
- display: inline-flex;
- align-items: center;
- margin-right: ${space(1)};
- * > img {
- box-shadow: none;
- }
- `;
|