changedTransactions.tsx 20 KB

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