teamMisery.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import {Fragment, useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {Location} from 'history';
  5. import AsyncComponent from 'app/components/asyncComponent';
  6. import {DateTimeObject} from 'app/components/charts/utils';
  7. import IdBadge from 'app/components/idBadge';
  8. import Link from 'app/components/links/link';
  9. import PanelTable from 'app/components/panels/panelTable';
  10. import {IconChevron, IconList} from 'app/icons';
  11. import {t, tct} from 'app/locale';
  12. import overflowEllipsis from 'app/styles/overflowEllipsis';
  13. import space from 'app/styles/space';
  14. import {Organization, Project, SavedQueryVersions} from 'app/types';
  15. import DiscoverQuery, {TableData, TableDataRow} from 'app/utils/discover/discoverQuery';
  16. import EventView from 'app/utils/discover/eventView';
  17. import {getFieldRenderer} from 'app/utils/discover/fieldRenderers';
  18. import type {Color} from 'app/utils/theme';
  19. import {transactionSummaryRouteWithQuery} from '../../performance/transactionSummary/utils';
  20. type TeamMiseryProps = {
  21. organization: Organization;
  22. location: Location;
  23. projects: Project[];
  24. periodTableData: TableData | null;
  25. weekTableData: TableData | null;
  26. isLoading: boolean;
  27. period?: string;
  28. };
  29. /** The number of elements to display before collapsing */
  30. const COLLAPSE_COUNT = 8;
  31. function TeamMisery({
  32. organization,
  33. location,
  34. projects,
  35. periodTableData,
  36. weekTableData,
  37. isLoading,
  38. period,
  39. }: TeamMiseryProps) {
  40. const [isExpanded, setIsExpanded] = useState(false);
  41. const miseryRenderer =
  42. periodTableData?.meta && getFieldRenderer('user_misery', periodTableData.meta);
  43. function expandResults() {
  44. setIsExpanded(true);
  45. }
  46. // Calculate trend, so we can sort based on it
  47. const sortedTableData = (periodTableData?.data ?? [])
  48. .map(dataRow => {
  49. const weekRow = weekTableData?.data.find(
  50. row => row.project === dataRow.project && row.transaction === dataRow.transaction
  51. );
  52. const trend = weekRow
  53. ? ((dataRow.user_misery as number) - (weekRow.user_misery as number)) * 100
  54. : null;
  55. return {
  56. ...dataRow,
  57. trend,
  58. } as TableDataRow & {trend: number};
  59. })
  60. .filter(x => x.trend !== null)
  61. .sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend));
  62. const worseItems = sortedTableData.filter(x => Math.round(x.trend) < 0);
  63. const betterItems = sortedTableData.filter(x => Math.round(x.trend) > 0);
  64. const zeroItems = sortedTableData.filter(x => Math.round(x.trend) === 0);
  65. const groupedData = [...worseItems, ...betterItems, ...zeroItems];
  66. return (
  67. <Fragment>
  68. <StyledPanelTable
  69. isEmpty={projects.length === 0 || periodTableData?.data.length === 0}
  70. headers={[
  71. t('Key transaction'),
  72. t('Project'),
  73. tct('Last [period]', {period}),
  74. t('Last 7 Days'),
  75. <RightAligned key="change">{t('Change')}</RightAligned>,
  76. ]}
  77. isLoading={isLoading}
  78. >
  79. {groupedData.map((dataRow, idx) => {
  80. const project = projects.find(({slug}) => dataRow.project === slug);
  81. const {trend, project: projectId, transaction} = dataRow;
  82. const weekRow = weekTableData?.data.find(
  83. row => row.project === projectId && row.transaction === transaction
  84. );
  85. if (!weekRow || trend === null) {
  86. return null;
  87. }
  88. const periodMisery = miseryRenderer?.(dataRow, {organization, location});
  89. const weekMisery =
  90. weekRow && miseryRenderer?.(weekRow, {organization, location});
  91. const trendValue = Math.round(Math.abs(trend));
  92. if (idx >= COLLAPSE_COUNT && !isExpanded) {
  93. return null;
  94. }
  95. return (
  96. <Fragment key={idx}>
  97. <TransactionWrapper>
  98. <Link
  99. to={transactionSummaryRouteWithQuery({
  100. orgSlug: organization.slug,
  101. transaction: dataRow.transaction as string,
  102. projectID: project?.id,
  103. query: {query: 'transaction.duration:<15m'},
  104. })}
  105. >
  106. {dataRow.transaction}
  107. </Link>
  108. </TransactionWrapper>
  109. <ProjectBadgeContainer>
  110. {project && <ProjectBadge avatarSize={18} project={project} />}
  111. </ProjectBadgeContainer>
  112. <div>{periodMisery}</div>
  113. <div>{weekMisery ?? '\u2014'}</div>
  114. <ScoreWrapper>
  115. {trendValue === 0 ? (
  116. <SubText>
  117. {`0\u0025 `}
  118. {t('change')}
  119. </SubText>
  120. ) : (
  121. <TrendText color={trend >= 0 ? 'green300' : 'red300'}>
  122. {`${trendValue}\u0025 `}
  123. {trend >= 0 ? t('better') : t('worse')}
  124. </TrendText>
  125. )}
  126. </ScoreWrapper>
  127. </Fragment>
  128. );
  129. })}
  130. </StyledPanelTable>
  131. {groupedData.length >= COLLAPSE_COUNT && !isExpanded && !isLoading && (
  132. <ShowMore onClick={expandResults}>
  133. <ShowMoreText>
  134. <StyledIconList color="gray300" />
  135. {tct('Show [count] More', {count: groupedData.length - 1 - COLLAPSE_COUNT})}
  136. </ShowMoreText>
  137. <IconChevron color="gray300" direction="down" />
  138. </ShowMore>
  139. )}
  140. </Fragment>
  141. );
  142. }
  143. type Props = AsyncComponent['props'] & {
  144. organization: Organization;
  145. projects: Project[];
  146. location: Location;
  147. period?: string;
  148. start?: string;
  149. end?: string;
  150. } & DateTimeObject;
  151. function TeamMiseryWrapper({
  152. organization,
  153. projects,
  154. location,
  155. period,
  156. start,
  157. end,
  158. }: Props) {
  159. if (projects.length === 0) {
  160. return (
  161. <TeamMisery
  162. isLoading={false}
  163. organization={organization}
  164. location={location}
  165. projects={[]}
  166. period={period}
  167. periodTableData={{data: []}}
  168. weekTableData={{data: []}}
  169. />
  170. );
  171. }
  172. const commonEventView = {
  173. id: undefined,
  174. query: 'transaction.duration:<15m team_key_transaction:true',
  175. projects: projects.map(project => Number(project.id)),
  176. version: 2 as SavedQueryVersions,
  177. orderby: '-tpm',
  178. fields: [
  179. 'transaction',
  180. 'project',
  181. 'tpm()',
  182. 'count_unique(user)',
  183. 'count_miserable(user)',
  184. 'user_misery()',
  185. ],
  186. };
  187. const periodEventView = EventView.fromSavedQuery({
  188. ...commonEventView,
  189. name: 'periodMisery',
  190. range: period,
  191. start,
  192. end,
  193. });
  194. const weekEventView = EventView.fromSavedQuery({
  195. ...commonEventView,
  196. name: 'weekMisery',
  197. range: '7d',
  198. });
  199. return (
  200. <DiscoverQuery
  201. eventView={periodEventView}
  202. orgSlug={organization.slug}
  203. location={location}
  204. >
  205. {({isLoading, tableData: periodTableData}) => (
  206. <DiscoverQuery
  207. eventView={weekEventView}
  208. orgSlug={organization.slug}
  209. location={location}
  210. >
  211. {({isLoading: isWeekLoading, tableData: weekTableData}) => (
  212. <TeamMisery
  213. isLoading={isLoading || isWeekLoading}
  214. organization={organization}
  215. location={location}
  216. projects={projects}
  217. period={period}
  218. periodTableData={periodTableData}
  219. weekTableData={weekTableData}
  220. />
  221. )}
  222. </DiscoverQuery>
  223. )}
  224. </DiscoverQuery>
  225. );
  226. }
  227. export default TeamMiseryWrapper;
  228. const StyledPanelTable = styled(PanelTable)<{isEmpty: boolean}>`
  229. grid-template-columns: 1fr 0.5fr 112px 112px 0.25fr;
  230. font-size: ${p => p.theme.fontSizeMedium};
  231. white-space: nowrap;
  232. margin-bottom: 0;
  233. border: 0;
  234. box-shadow: unset;
  235. & > div {
  236. padding: ${space(1)} ${space(2)};
  237. }
  238. ${p =>
  239. p.isEmpty &&
  240. css`
  241. & > div:last-child {
  242. padding: 48px ${space(2)};
  243. }
  244. `}
  245. `;
  246. const ProjectBadgeContainer = styled('div')`
  247. display: flex;
  248. `;
  249. const ProjectBadge = styled(IdBadge)`
  250. flex-shrink: 0;
  251. `;
  252. const TransactionWrapper = styled('div')`
  253. ${overflowEllipsis};
  254. `;
  255. const RightAligned = styled('span')`
  256. text-align: right;
  257. `;
  258. const ScoreWrapper = styled('div')`
  259. display: flex;
  260. align-items: center;
  261. justify-content: flex-end;
  262. text-align: right;
  263. `;
  264. const SubText = styled('div')`
  265. color: ${p => p.theme.subText};
  266. `;
  267. const TrendText = styled('div')<{color: Color}>`
  268. color: ${p => p.theme[p.color]};
  269. `;
  270. const ShowMore = styled('div')`
  271. display: flex;
  272. align-items: center;
  273. padding: ${space(1)} ${space(2)};
  274. font-size: ${p => p.theme.fontSizeMedium};
  275. color: ${p => p.theme.subText};
  276. cursor: pointer;
  277. border-top: 1px solid ${p => p.theme.border};
  278. `;
  279. const StyledIconList = styled(IconList)`
  280. margin-right: ${space(1)};
  281. `;
  282. const ShowMoreText = styled('div')`
  283. display: flex;
  284. align-items: center;
  285. flex-grow: 1;
  286. `;