changedTransactions.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. import {Fragment} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location} from 'history';
  5. import {Client} from 'sentry/api';
  6. import {Button} from 'sentry/components/button';
  7. import {HeaderTitleLegend} from 'sentry/components/charts/styles';
  8. import Count from 'sentry/components/count';
  9. import DropdownLink from 'sentry/components/dropdownLink';
  10. import Duration from 'sentry/components/duration';
  11. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  12. import {RadioLineItem} from 'sentry/components/forms/controls/radioGroup';
  13. import IdBadge from 'sentry/components/idBadge';
  14. import Link from 'sentry/components/links/link';
  15. import LoadingIndicator from 'sentry/components/loadingIndicator';
  16. import MenuItem from 'sentry/components/menuItem';
  17. import Pagination, {CursorHandler} from 'sentry/components/pagination';
  18. import {Panel} from 'sentry/components/panels';
  19. import QuestionTooltip from 'sentry/components/questionTooltip';
  20. import Radio from 'sentry/components/radio';
  21. import {Tooltip} from 'sentry/components/tooltip';
  22. import {IconArrow, IconEllipsis} from 'sentry/icons';
  23. import {t} from 'sentry/locale';
  24. import space from 'sentry/styles/space';
  25. import {AvatarProject, Organization, Project} from 'sentry/types';
  26. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  27. import {formatPercentage, getDuration} from 'sentry/utils/formatters';
  28. import TrendsDiscoverQuery from 'sentry/utils/performance/trends/trendsDiscoverQuery';
  29. import {decodeScalar} from 'sentry/utils/queryString';
  30. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  31. import useApi from 'sentry/utils/useApi';
  32. import withOrganization from 'sentry/utils/withOrganization';
  33. import withProjects from 'sentry/utils/withProjects';
  34. import {
  35. DisplayModes,
  36. transactionSummaryRouteWithQuery,
  37. } from 'sentry/views/performance/transactionSummary/utils';
  38. import Chart from './chart';
  39. import {
  40. NormalizedTrendsTransaction,
  41. TrendChangeType,
  42. TrendColumnField,
  43. TrendFunctionField,
  44. TrendsStats,
  45. TrendView,
  46. } from './types';
  47. import {
  48. getCurrentTrendFunction,
  49. getCurrentTrendParameter,
  50. getSelectedQueryKey,
  51. getTrendProjectId,
  52. modifyTrendView,
  53. normalizeTrends,
  54. transformDeltaSpread,
  55. transformValueDelta,
  56. trendCursorNames,
  57. trendToColor,
  58. } from './utils';
  59. type Props = {
  60. location: Location;
  61. organization: Organization;
  62. projects: Project[];
  63. setError: (msg: string | undefined) => void;
  64. trendChangeType: TrendChangeType;
  65. trendView: TrendView;
  66. previousTrendColumn?: TrendColumnField;
  67. previousTrendFunction?: TrendFunctionField;
  68. };
  69. type TrendsCursorQuery = {
  70. improvedCursor?: string;
  71. regressionCursor?: string;
  72. };
  73. const makeTrendsCursorHandler =
  74. (trendChangeType: TrendChangeType): CursorHandler =>
  75. (cursor, path, query) => {
  76. const cursorQuery = {} as TrendsCursorQuery;
  77. if (trendChangeType === TrendChangeType.IMPROVED) {
  78. cursorQuery.improvedCursor = cursor;
  79. } else if (trendChangeType === TrendChangeType.REGRESSION) {
  80. cursorQuery.regressionCursor = cursor;
  81. }
  82. const selectedQueryKey = getSelectedQueryKey(trendChangeType);
  83. delete query[selectedQueryKey];
  84. browserHistory.push({
  85. pathname: path,
  86. query: {...query, ...cursorQuery},
  87. });
  88. };
  89. function getChartTitle(trendChangeType: TrendChangeType): string {
  90. switch (trendChangeType) {
  91. case TrendChangeType.IMPROVED:
  92. return t('Most Improved Transactions');
  93. case TrendChangeType.REGRESSION:
  94. return t('Most Regressed Transactions');
  95. default:
  96. throw new Error('No trend type passed');
  97. }
  98. }
  99. function getSelectedTransaction(
  100. location: Location,
  101. trendChangeType: TrendChangeType,
  102. transactions?: NormalizedTrendsTransaction[]
  103. ): NormalizedTrendsTransaction | undefined {
  104. const queryKey = getSelectedQueryKey(trendChangeType);
  105. const selectedTransactionName = decodeScalar(location.query[queryKey]);
  106. if (!transactions) {
  107. return undefined;
  108. }
  109. const selectedTransaction = transactions.find(
  110. transaction =>
  111. `${transaction.transaction}-${transaction.project}` === selectedTransactionName
  112. );
  113. if (selectedTransaction) {
  114. return selectedTransaction;
  115. }
  116. return transactions.length > 0 ? transactions[0] : undefined;
  117. }
  118. function handleChangeSelected(
  119. location: Location,
  120. organization: Organization,
  121. trendChangeType: TrendChangeType
  122. ) {
  123. return function updateSelected(transaction?: NormalizedTrendsTransaction) {
  124. const selectedQueryKey = getSelectedQueryKey(trendChangeType);
  125. const query = {
  126. ...location.query,
  127. };
  128. if (!transaction) {
  129. delete query[selectedQueryKey];
  130. } else {
  131. query[selectedQueryKey] = transaction
  132. ? `${transaction.transaction}-${transaction.project}`
  133. : undefined;
  134. }
  135. browserHistory.push({
  136. pathname: location.pathname,
  137. query,
  138. });
  139. trackAdvancedAnalyticsEvent('performance_views.trends.widget_interaction', {
  140. organization,
  141. widget_type: trendChangeType,
  142. });
  143. };
  144. }
  145. enum FilterSymbols {
  146. GREATER_THAN_EQUALS = '>=',
  147. LESS_THAN_EQUALS = '<=',
  148. }
  149. function handleFilterTransaction(location: Location, transaction: string) {
  150. const queryString = decodeScalar(location.query.query);
  151. const conditions = new MutableSearch(queryString ?? '');
  152. conditions.addFilterValues('!transaction', [transaction]);
  153. const query = conditions.formatString();
  154. browserHistory.push({
  155. pathname: location.pathname,
  156. query: {
  157. ...location.query,
  158. query: String(query).trim(),
  159. },
  160. });
  161. }
  162. function handleFilterDuration(
  163. location: Location,
  164. organization: Organization,
  165. value: number,
  166. symbol: FilterSymbols,
  167. trendChangeType: TrendChangeType,
  168. projects: Project[],
  169. projectIds: Readonly<number[]>
  170. ) {
  171. const durationTag = getCurrentTrendParameter(location, projects, projectIds).column;
  172. const queryString = decodeScalar(location.query.query);
  173. const conditions = new MutableSearch(queryString ?? '');
  174. const existingValues = conditions.getFilterValues(durationTag);
  175. const alternateSymbol = symbol === FilterSymbols.GREATER_THAN_EQUALS ? '>' : '<';
  176. if (existingValues) {
  177. existingValues.forEach(existingValue => {
  178. if (existingValue.startsWith(symbol) || existingValue.startsWith(alternateSymbol)) {
  179. conditions.removeFilterValue(durationTag, existingValue);
  180. }
  181. });
  182. }
  183. conditions.addFilterValues(durationTag, [`${symbol}${value}`]);
  184. const query = conditions.formatString();
  185. browserHistory.push({
  186. pathname: location.pathname,
  187. query: {
  188. ...location.query,
  189. query: String(query).trim(),
  190. },
  191. });
  192. trackAdvancedAnalyticsEvent('performance_views.trends.change_duration', {
  193. organization,
  194. widget_type: getChartTitle(trendChangeType),
  195. value: `${symbol}${value}`,
  196. });
  197. }
  198. function ChangedTransactions(props: Props) {
  199. const {
  200. location,
  201. trendChangeType,
  202. previousTrendFunction,
  203. previousTrendColumn,
  204. organization,
  205. projects,
  206. setError,
  207. } = props;
  208. const api = useApi();
  209. const trendView = props.trendView.clone();
  210. const chartTitle = getChartTitle(trendChangeType);
  211. modifyTrendView(trendView, location, trendChangeType, projects);
  212. const onCursor = makeTrendsCursorHandler(trendChangeType);
  213. const cursor = decodeScalar(location.query[trendCursorNames[trendChangeType]]);
  214. const paginationAnalyticsEvent = (direction: string) => {
  215. trackAdvancedAnalyticsEvent('performance_views.trends.widget_pagination', {
  216. organization,
  217. direction,
  218. widget_type: getChartTitle(trendChangeType),
  219. });
  220. };
  221. return (
  222. <TrendsDiscoverQuery
  223. eventView={trendView}
  224. orgSlug={organization.slug}
  225. location={location}
  226. trendChangeType={trendChangeType}
  227. cursor={cursor}
  228. limit={5}
  229. setError={error => setError(error?.message)}
  230. >
  231. {({isLoading, trendsData, pageLinks}) => {
  232. const trendFunction = getCurrentTrendFunction(location);
  233. const trendParameter = getCurrentTrendParameter(
  234. location,
  235. projects,
  236. trendView.project
  237. );
  238. const events = normalizeTrends(
  239. (trendsData && trendsData.events && trendsData.events.data) || []
  240. );
  241. const selectedTransaction = getSelectedTransaction(
  242. location,
  243. trendChangeType,
  244. events
  245. );
  246. const statsData = trendsData?.stats || {};
  247. const transactionsList = events && events.slice ? events.slice(0, 5) : [];
  248. const currentTrendFunction =
  249. isLoading && previousTrendFunction
  250. ? previousTrendFunction
  251. : trendFunction.field;
  252. const currentTrendColumn =
  253. isLoading && previousTrendColumn ? previousTrendColumn : trendParameter.column;
  254. const titleTooltipContent = t(
  255. 'This compares the baseline (%s) of the past with the present.',
  256. trendFunction.legendLabel
  257. );
  258. return (
  259. <TransactionsListContainer data-test-id="changed-transactions">
  260. <TrendsTransactionPanel>
  261. <StyledHeaderTitleLegend>
  262. {chartTitle}
  263. <QuestionTooltip size="sm" position="top" title={titleTooltipContent} />
  264. </StyledHeaderTitleLegend>
  265. {isLoading ? (
  266. <LoadingIndicator
  267. style={{
  268. margin: '237px auto',
  269. }}
  270. />
  271. ) : (
  272. <Fragment>
  273. {transactionsList.length ? (
  274. <Fragment>
  275. <ChartContainer>
  276. <Chart
  277. statsData={statsData}
  278. query={trendView.query}
  279. project={trendView.project}
  280. environment={trendView.environment}
  281. start={trendView.start}
  282. end={trendView.end}
  283. statsPeriod={trendView.statsPeriod}
  284. transaction={selectedTransaction}
  285. isLoading={isLoading}
  286. {...props}
  287. />
  288. </ChartContainer>
  289. {transactionsList.map((transaction, index) => (
  290. <TrendsListItem
  291. api={api}
  292. currentTrendFunction={currentTrendFunction}
  293. currentTrendColumn={currentTrendColumn}
  294. trendView={props.trendView}
  295. organization={organization}
  296. transaction={transaction}
  297. key={transaction.transaction}
  298. index={index}
  299. trendChangeType={trendChangeType}
  300. transactions={transactionsList}
  301. location={location}
  302. projects={projects}
  303. statsData={statsData}
  304. handleSelectTransaction={handleChangeSelected(
  305. location,
  306. organization,
  307. trendChangeType
  308. )}
  309. />
  310. ))}
  311. </Fragment>
  312. ) : (
  313. <StyledEmptyStateWarning small>
  314. {t('No results')}
  315. </StyledEmptyStateWarning>
  316. )}
  317. </Fragment>
  318. )}
  319. </TrendsTransactionPanel>
  320. <Pagination
  321. pageLinks={pageLinks}
  322. onCursor={onCursor}
  323. paginationAnalyticsEvent={paginationAnalyticsEvent}
  324. />
  325. </TransactionsListContainer>
  326. );
  327. }}
  328. </TrendsDiscoverQuery>
  329. );
  330. }
  331. type TrendsListItemProps = {
  332. api: Client;
  333. currentTrendColumn: string;
  334. currentTrendFunction: string;
  335. handleSelectTransaction: (transaction: NormalizedTrendsTransaction) => void;
  336. index: number;
  337. location: Location;
  338. organization: Organization;
  339. projects: Project[];
  340. statsData: TrendsStats;
  341. transaction: NormalizedTrendsTransaction;
  342. transactions: NormalizedTrendsTransaction[];
  343. trendChangeType: TrendChangeType;
  344. trendView: TrendView;
  345. };
  346. function TrendsListItem(props: TrendsListItemProps) {
  347. const {
  348. transaction,
  349. transactions,
  350. trendChangeType,
  351. currentTrendFunction,
  352. currentTrendColumn,
  353. index,
  354. location,
  355. organization,
  356. projects,
  357. handleSelectTransaction,
  358. trendView,
  359. } = props;
  360. const color = trendToColor[trendChangeType].default;
  361. const selectedTransaction = getSelectedTransaction(
  362. location,
  363. trendChangeType,
  364. transactions
  365. );
  366. const isSelected = selectedTransaction === transaction;
  367. const project = projects.find(
  368. ({slug}) => slug === transaction.project
  369. ) as AvatarProject;
  370. const currentPeriodValue = transaction.aggregate_range_2;
  371. const previousPeriodValue = transaction.aggregate_range_1;
  372. const absolutePercentChange = formatPercentage(
  373. Math.abs(transaction.trend_percentage - 1),
  374. 0
  375. );
  376. const previousDuration = getDuration(
  377. previousPeriodValue / 1000,
  378. previousPeriodValue < 1000 && previousPeriodValue > 10 ? 0 : 2
  379. );
  380. const currentDuration = getDuration(
  381. currentPeriodValue / 1000,
  382. currentPeriodValue < 1000 && currentPeriodValue > 10 ? 0 : 2
  383. );
  384. const percentChangeExplanation = t(
  385. 'Over this period, the %s for %s has %s %s from %s to %s',
  386. currentTrendFunction,
  387. currentTrendColumn,
  388. trendChangeType === TrendChangeType.IMPROVED ? t('decreased') : t('increased'),
  389. absolutePercentChange,
  390. previousDuration,
  391. currentDuration
  392. );
  393. const longestPeriodValue =
  394. trendChangeType === TrendChangeType.IMPROVED
  395. ? previousPeriodValue
  396. : currentPeriodValue;
  397. const longestDuration =
  398. trendChangeType === TrendChangeType.IMPROVED ? previousDuration : currentDuration;
  399. return (
  400. <ListItemContainer data-test-id={'trends-list-item-' + trendChangeType}>
  401. <ItemRadioContainer color={color}>
  402. <Tooltip
  403. title={
  404. <TooltipContent>
  405. <span>{t('Total Events')}</span>
  406. <span>
  407. <Count value={transaction.count_range_1} />
  408. <StyledIconArrow direction="right" size="xs" />
  409. <Count value={transaction.count_range_2} />
  410. </span>
  411. </TooltipContent>
  412. }
  413. disableForVisualTest // Disabled tooltip in snapshots because of overlap order issues.
  414. >
  415. <RadioLineItem index={index} role="radio">
  416. <Radio
  417. checked={isSelected}
  418. onChange={() => handleSelectTransaction(transaction)}
  419. />
  420. </RadioLineItem>
  421. </Tooltip>
  422. </ItemRadioContainer>
  423. <TransactionSummaryLink {...props} />
  424. <ItemTransactionPercentage>
  425. <Tooltip title={percentChangeExplanation}>
  426. <Fragment>
  427. {trendChangeType === TrendChangeType.REGRESSION ? '+' : ''}
  428. {formatPercentage(transaction.trend_percentage - 1, 0)}
  429. </Fragment>
  430. </Tooltip>
  431. </ItemTransactionPercentage>
  432. <DropdownLink
  433. caret={false}
  434. anchorRight
  435. title={
  436. <StyledButton
  437. size="xs"
  438. icon={<IconEllipsis data-test-id="trends-item-action" size="xs" />}
  439. aria-label={t('Actions')}
  440. />
  441. }
  442. >
  443. <MenuItem
  444. onClick={() =>
  445. handleFilterDuration(
  446. location,
  447. organization,
  448. longestPeriodValue,
  449. FilterSymbols.LESS_THAN_EQUALS,
  450. trendChangeType,
  451. projects,
  452. trendView.project
  453. )
  454. }
  455. >
  456. <MenuAction>{t('Show \u2264 %s', longestDuration)}</MenuAction>
  457. </MenuItem>
  458. <MenuItem
  459. onClick={() =>
  460. handleFilterDuration(
  461. location,
  462. organization,
  463. longestPeriodValue,
  464. FilterSymbols.GREATER_THAN_EQUALS,
  465. trendChangeType,
  466. projects,
  467. trendView.project
  468. )
  469. }
  470. >
  471. <MenuAction>{t('Show \u2265 %s', longestDuration)}</MenuAction>
  472. </MenuItem>
  473. <MenuItem
  474. onClick={() => handleFilterTransaction(location, transaction.transaction)}
  475. >
  476. <MenuAction>{t('Hide from list')}</MenuAction>
  477. </MenuItem>
  478. </DropdownLink>
  479. <ItemTransactionDurationChange>
  480. {project && (
  481. <Tooltip title={transaction.project}>
  482. <IdBadge avatarSize={16} project={project} hideName />
  483. </Tooltip>
  484. )}
  485. <CompareDurations {...props} />
  486. </ItemTransactionDurationChange>
  487. <ItemTransactionStatus color={color}>
  488. <ValueDelta {...props} />
  489. </ItemTransactionStatus>
  490. </ListItemContainer>
  491. );
  492. }
  493. export const CompareDurations = ({
  494. transaction,
  495. }: {
  496. transaction: TrendsListItemProps['transaction'];
  497. }) => {
  498. const {fromSeconds, toSeconds, showDigits} = transformDeltaSpread(
  499. transaction.aggregate_range_1,
  500. transaction.aggregate_range_2
  501. );
  502. return (
  503. <DurationChange>
  504. <Duration seconds={fromSeconds} fixedDigits={showDigits ? 1 : 0} abbreviation />
  505. <StyledIconArrow direction="right" size="xs" />
  506. <Duration seconds={toSeconds} fixedDigits={showDigits ? 1 : 0} abbreviation />
  507. </DurationChange>
  508. );
  509. };
  510. const ValueDelta = ({transaction, trendChangeType}: TrendsListItemProps) => {
  511. const {seconds, fixedDigits, changeLabel} = transformValueDelta(
  512. transaction.trend_difference,
  513. trendChangeType
  514. );
  515. return (
  516. <span>
  517. <Duration seconds={seconds} fixedDigits={fixedDigits} abbreviation /> {changeLabel}
  518. </span>
  519. );
  520. };
  521. type TransactionSummaryLinkProps = TrendsListItemProps & {};
  522. const TransactionSummaryLink = (props: TransactionSummaryLinkProps) => {
  523. const {
  524. organization,
  525. trendView: eventView,
  526. transaction,
  527. projects,
  528. currentTrendFunction,
  529. currentTrendColumn,
  530. } = props;
  531. const summaryView = eventView.clone();
  532. const projectID = getTrendProjectId(transaction, projects);
  533. const target = transactionSummaryRouteWithQuery({
  534. orgSlug: organization.slug,
  535. transaction: String(transaction.transaction),
  536. query: summaryView.generateQueryStringObject(),
  537. projectID,
  538. display: DisplayModes.TREND,
  539. trendFunction: currentTrendFunction,
  540. trendColumn: currentTrendColumn,
  541. });
  542. return (
  543. <ItemTransactionName to={target} data-test-id="item-transaction-name">
  544. {transaction.transaction}
  545. </ItemTransactionName>
  546. );
  547. };
  548. const TransactionsListContainer = styled('div')`
  549. display: flex;
  550. flex-direction: column;
  551. `;
  552. const TrendsTransactionPanel = styled(Panel)`
  553. margin: 0;
  554. flex-grow: 1;
  555. `;
  556. const ChartContainer = styled('div')`
  557. padding: ${space(3)};
  558. `;
  559. const StyledHeaderTitleLegend = styled(HeaderTitleLegend)`
  560. border-radius: ${p => p.theme.borderRadius};
  561. margin: ${space(2)} ${space(3)};
  562. `;
  563. const StyledButton = styled(Button)`
  564. vertical-align: middle;
  565. `;
  566. const MenuAction = styled('div')<{['data-test-id']?: string}>`
  567. white-space: nowrap;
  568. color: ${p => p.theme.textColor};
  569. `;
  570. MenuAction.defaultProps = {
  571. 'data-test-id': 'menu-action',
  572. };
  573. const StyledEmptyStateWarning = styled(EmptyStateWarning)`
  574. min-height: 300px;
  575. justify-content: center;
  576. `;
  577. const ListItemContainer = styled('div')`
  578. display: grid;
  579. grid-template-columns: 24px auto 100px 30px;
  580. grid-template-rows: repeat(2, auto);
  581. grid-column-gap: ${space(1)};
  582. border-top: 1px solid ${p => p.theme.border};
  583. padding: ${space(1)} ${space(2)};
  584. `;
  585. const ItemRadioContainer = styled('div')`
  586. grid-row: 1/3;
  587. input {
  588. cursor: pointer;
  589. }
  590. input:checked::after {
  591. background-color: ${p => p.color};
  592. }
  593. `;
  594. const ItemTransactionName = styled(Link)`
  595. font-size: ${p => p.theme.fontSizeMedium};
  596. margin-right: ${space(1)};
  597. ${p => p.theme.overflowEllipsis};
  598. `;
  599. const ItemTransactionDurationChange = styled('div')`
  600. display: flex;
  601. align-items: center;
  602. font-size: ${p => p.theme.fontSizeSmall};
  603. `;
  604. const DurationChange = styled('span')`
  605. color: ${p => p.theme.gray300};
  606. margin: 0 ${space(1)};
  607. `;
  608. const ItemTransactionPercentage = styled('div')`
  609. text-align: right;
  610. font-size: ${p => p.theme.fontSizeMedium};
  611. `;
  612. const ItemTransactionStatus = styled('div')`
  613. color: ${p => p.color};
  614. text-align: right;
  615. font-size: ${p => p.theme.fontSizeSmall};
  616. `;
  617. const TooltipContent = styled('div')`
  618. display: flex;
  619. flex-direction: column;
  620. align-items: center;
  621. `;
  622. const StyledIconArrow = styled(IconArrow)`
  623. margin: 0 ${space(1)};
  624. `;
  625. export default withProjects(withOrganization(ChangedTransactions));