changedTransactions.tsx 18 KB

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