changedTransactions.tsx 22 KB

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