changedTransactions.tsx 20 KB

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