Browse Source

ref(perf): Use generic transactions list component (#22371)

Replace the custom transactions list in transaction summary with the new generic
transactions list component.
Tony 4 years ago
parent
commit
fff1826301

+ 1 - 1
src/sentry/static/sentry/app/__mocks__/api.tsx

@@ -119,7 +119,7 @@ class Client {
     if (!response || !mock) {
       // Endpoints need to be mocked
       const err = new Error(
-        `No mocked response found for request:\n\t${options.method || 'GET'} ${url}`
+        `No mocked response found for request: ${options.method || 'GET'} ${url}`
       );
 
       // Mutate stack to drop frames since test file so that we know where in the test

+ 185 - 44
src/sentry/static/sentry/app/components/discover/transactionsList.tsx

@@ -19,18 +19,25 @@ import DiscoverQuery, {TableData, TableDataRow} from 'app/utils/discover/discove
 import EventView, {MetaType} from 'app/utils/discover/eventView';
 import {getFieldRenderer} from 'app/utils/discover/fieldRenderers';
 import {getAggregateAlias, Sort} from 'app/utils/discover/fields';
+import {generateEventSlug} from 'app/utils/discover/urls';
+import {getDuration} from 'app/utils/formatters';
 import {decodeScalar} from 'app/utils/queryString';
 import {stringifyQueryObject, tokenizeSearch} from 'app/utils/tokenizeSearch';
+import CellAction, {Actions} from 'app/views/eventsV2/table/cellAction';
 import HeaderCell from 'app/views/eventsV2/table/headerCell';
 import {TableColumn} from 'app/views/eventsV2/table/types';
 import {decodeColumnOrder} from 'app/views/eventsV2/utils';
 import {GridCell, GridCellNumber} from 'app/views/performance/styles';
+import BaselineQuery, {
+  BaselineQueryResults,
+} from 'app/views/performance/transactionSummary/baselineQuery';
 import {TrendsEventsDiscoverQuery} from 'app/views/performance/trends/trendsDiscoverQuery';
 import {
   TrendChangeType,
   TrendsDataEvents,
   TrendView,
 } from 'app/views/performance/trends/types';
+import {getTransactionComparisonUrl} from 'app/views/performance/utils';
 
 const DEFAULT_TRANSACTION_LIMIT = 5;
 
@@ -73,7 +80,13 @@ type Props = {
   /**
    * The callback for when the dropdown option changes.
    */
-  handleDropdownChange: any;
+  handleDropdownChange: (k: string) => void;
+  /**
+   * The callback to generate a cell action handler for a column
+   */
+  handleCellAction?: (
+    c: TableColumn<React.ReactText>
+  ) => (a: Actions, v: React.ReactText) => void;
   /**
    * The name of the url parameter that contains the cursor info.
    */
@@ -86,10 +99,6 @@ type Props = {
    * A list of preferred table headers to use over the field names.
    */
   titles?: string[];
-  /**
-   * Alternate data-test-id to use for the optional links in the first column.
-   */
-  linkDataTestId?: string;
   /**
    * A map of callbacks to generate a link for a column based on the title.
    */
@@ -101,6 +110,18 @@ type Props = {
       query: Query
     ) => LocationDescriptor
   >;
+  /**
+   * The name of the transaction to find a baseline for.
+   */
+  baseline?: string;
+  /**
+   * The callback for when a baseline cell is clicked.
+   */
+  handleBaselineClick?: (e: React.MouseEvent<Element>) => void;
+  /**
+   * The callback for when Open in Discover is clicked.
+   */
+  handleOpenInDiscoverClick?: (e: React.MouseEvent<Element>) => void;
 };
 
 class TransactionsList extends React.Component<Props> {
@@ -118,7 +139,14 @@ class TransactionsList extends React.Component<Props> {
   };
 
   renderHeader(): React.ReactNode {
-    const {eventView, organization, selected, options, handleDropdownChange} = this.props;
+    const {
+      eventView,
+      organization,
+      selected,
+      options,
+      handleDropdownChange,
+      handleOpenInDiscoverClick,
+    } = this.props;
 
     return (
       <Header>
@@ -150,6 +178,7 @@ class TransactionsList extends React.Component<Props> {
         {!this.isTrend() && (
           <HeaderButtonContainer>
             <DiscoverButton
+              onClick={handleOpenInDiscoverClick}
               to={eventView
                 .withSorts([selected.sort])
                 .getResultsViewUrlTarget(organization.slug)}
@@ -170,11 +199,12 @@ class TransactionsList extends React.Component<Props> {
       location,
       organization,
       selected,
+      handleCellAction,
       cursorName,
       limit,
       titles,
-      linkDataTestId,
       generateLink,
+      baseline,
     } = this.props;
     const sortedEventView = eventView.withSorts([selected.sort]);
     const columnOrder = sortedEventView.getColumns();
@@ -185,6 +215,51 @@ class TransactionsList extends React.Component<Props> {
       sortedEventView.query = stringifyQueryObject(query);
     }
 
+    const baselineTransactionName = organization.features.includes(
+      'transaction-comparison'
+    )
+      ? baseline ?? null
+      : null;
+
+    let tableRenderer = ({isLoading, pageLinks, tableData, baselineData}) => (
+      <React.Fragment>
+        <TransactionsTable
+          eventView={sortedEventView}
+          organization={organization}
+          location={location}
+          isLoading={isLoading}
+          tableData={tableData}
+          baselineData={baselineData ?? null}
+          columnOrder={columnOrder}
+          titles={titles}
+          generateLink={generateLink}
+          baselineTransactionName={baselineTransactionName}
+          handleCellAction={handleCellAction}
+        />
+        <StyledPagination
+          pageLinks={pageLinks}
+          onCursor={this.handleCursor}
+          size="small"
+        />
+      </React.Fragment>
+    );
+
+    if (baselineTransactionName) {
+      const orgTableRenderer = tableRenderer;
+      tableRenderer = ({isLoading, pageLinks, tableData}) => (
+        <BaselineQuery eventView={eventView} orgSlug={organization.slug}>
+          {baselineQueryProps => {
+            return orgTableRenderer({
+              isLoading: isLoading || baselineQueryProps.isLoading,
+              pageLinks,
+              tableData,
+              baselineData: baselineQueryProps.results,
+            });
+          }}
+        </BaselineQuery>
+      );
+    }
+
     return (
       <DiscoverQuery
         location={location}
@@ -193,26 +268,7 @@ class TransactionsList extends React.Component<Props> {
         limit={limit}
         cursor={cursor}
       >
-        {({isLoading, tableData, pageLinks}) => (
-          <React.Fragment>
-            <TransactionsTable
-              eventView={sortedEventView}
-              organization={organization}
-              location={location}
-              isLoading={isLoading}
-              tableData={tableData}
-              columnOrder={columnOrder}
-              titles={titles}
-              linkDataTestId={linkDataTestId}
-              generateLink={generateLink}
-            />
-            <StyledPagination
-              pageLinks={pageLinks}
-              onCursor={this.handleCursor}
-              size="small"
-            />
-          </React.Fragment>
-        )}
+        {tableRenderer}
       </DiscoverQuery>
     );
   }
@@ -224,7 +280,6 @@ class TransactionsList extends React.Component<Props> {
       selected,
       organization,
       cursorName,
-      linkDataTestId,
       generateLink,
     } = this.props;
 
@@ -254,14 +309,15 @@ class TransactionsList extends React.Component<Props> {
               location={location}
               isLoading={isLoading}
               tableData={trendsData}
+              baselineData={null}
               titles={['transaction', 'percentage', 'difference']}
               columnOrder={decodeColumnOrder([
                 {field: 'transaction'},
                 {field: 'trend_percentage()'},
                 {field: 'trend_difference()'},
               ])}
-              linkDataTestId={linkDataTestId}
               generateLink={generateLink}
+              baselineTransactionName={null}
             />
             <StyledPagination
               pageLinks={pageLinks}
@@ -294,10 +350,12 @@ type TableProps = {
   organization: Organization;
   location: Location;
   isLoading: boolean;
-  tableData: TableData | TrendsDataEvents | null | undefined;
+  tableData: TableData | TrendsDataEvents | null;
   columnOrder: TableColumn<React.ReactText>[];
   titles?: string[];
-  linkDataTestId?: string;
+  baselineTransactionName: string | null;
+  baselineData: BaselineQueryResults | null;
+  handleBaselineClick?: (e: React.MouseEvent<Element>) => void;
   generateLink?: Record<
     string,
     (
@@ -306,6 +364,9 @@ type TableProps = {
       query: Query
     ) => LocationDescriptor
   >;
+  handleCellAction?: (
+    c: TableColumn<React.ReactText>
+  ) => (a: Actions, v: React.ReactText) => void;
 };
 
 class TransactionsTable extends React.PureComponent<TableProps> {
@@ -315,13 +376,13 @@ class TransactionsTable extends React.PureComponent<TableProps> {
   }
 
   renderHeader() {
-    const {tableData, columnOrder} = this.props;
+    const {tableData, columnOrder, baselineTransactionName} = this.props;
 
     const tableMeta = tableData?.meta;
     const generateSortLink = () => undefined;
     const tableTitles = this.getTitles();
 
-    return tableTitles.map((title, index) => (
+    const headers = tableTitles.map((title, index) => (
       <HeaderCell column={columnOrder[index]} tableMeta={tableMeta} key={index}>
         {({align}) => {
           return (
@@ -338,6 +399,22 @@ class TransactionsTable extends React.PureComponent<TableProps> {
         }}
       </HeaderCell>
     ));
+
+    if (baselineTransactionName) {
+      headers.push(
+        <HeadCellContainer key="baseline">
+          <SortLink
+            align="right"
+            title={t('Compared to Baseline')}
+            direction={undefined}
+            canSort={false}
+            generateSortLink={generateSortLink}
+          />
+        </HeadCellContainer>
+      );
+    }
+
+    return headers;
   }
 
   renderRow(
@@ -346,7 +423,17 @@ class TransactionsTable extends React.PureComponent<TableProps> {
     columnOrder: TableColumn<React.ReactText>[],
     tableMeta: MetaType
   ): React.ReactNode[] {
-    const {organization, location, linkDataTestId, generateLink} = this.props;
+    const {
+      eventView,
+      organization,
+      location,
+      generateLink,
+      baselineTransactionName,
+      baselineData,
+      handleBaselineClick,
+      handleCellAction,
+    } = this.props;
+    const fields = eventView.getFields();
     const tableTitles = this.getTitles();
 
     const resultsRow = columnOrder.map((column, index) => {
@@ -366,7 +453,7 @@ class TransactionsTable extends React.PureComponent<TableProps> {
 
       if (target) {
         rendered = (
-          <Link data-test-id={linkDataTestId ?? 'transactions-list-link'} to={target}>
+          <Link data-test-id={`view-${fields[index]}`} to={target}>
             {rendered}
           </Link>
         );
@@ -374,18 +461,72 @@ class TransactionsTable extends React.PureComponent<TableProps> {
 
       const isNumeric = ['integer', 'number', 'duration'].includes(fieldType);
       const key = `${rowIndex}:${column.key}:${index}`;
-
-      return (
-        <BodyCellContainer key={key}>
-          {isNumeric ? (
-            <GridCellNumber>{rendered}</GridCellNumber>
-          ) : (
-            <GridCell>{rendered}</GridCell>
-          )}
-        </BodyCellContainer>
+      rendered = isNumeric ? (
+        <GridCellNumber>{rendered}</GridCellNumber>
+      ) : (
+        <GridCell>{rendered}</GridCell>
       );
+
+      if (handleCellAction) {
+        rendered = (
+          <CellAction
+            column={column}
+            dataRow={row}
+            handleCellAction={handleCellAction(column)}
+          >
+            {rendered}
+          </CellAction>
+        );
+      }
+
+      return <BodyCellContainer key={key}>{rendered}</BodyCellContainer>;
     });
 
+    if (baselineTransactionName) {
+      if (baselineData) {
+        const currentTransactionDuration: number =
+          Number(row['transaction.duration']) || 0;
+        const duration = baselineData['transaction.duration'];
+
+        const delta = Math.abs(currentTransactionDuration - duration);
+
+        const relativeSpeed =
+          currentTransactionDuration < duration
+            ? t('faster')
+            : currentTransactionDuration > duration
+            ? t('slower')
+            : '';
+
+        const target = getTransactionComparisonUrl({
+          organization,
+          baselineEventSlug: generateEventSlug(baselineData),
+          regressionEventSlug: generateEventSlug(row),
+          transaction: baselineTransactionName,
+          query: location.query,
+        });
+
+        resultsRow.push(
+          <BodyCellContainer
+            data-test-id="baseline-cell"
+            key={`${rowIndex}-baseline`}
+            style={{textAlign: 'right'}}
+          >
+            <GridCell>
+              <Link to={target} onClick={handleBaselineClick}>
+                {`${getDuration(delta / 1000, delta < 1000 ? 0 : 2)} ${relativeSpeed}`}
+              </Link>
+            </GridCell>
+          </BodyCellContainer>
+        );
+      } else {
+        resultsRow.push(
+          <BodyCellContainer data-test-id="baseline-cell" key={`${rowIndex}-baseline`}>
+            {'\u2014'}
+          </BodyCellContainer>
+        );
+      }
+    }
+
     return resultsRow;
   }
 

+ 129 - 6
src/sentry/static/sentry/app/views/performance/transactionSummary/content.tsx

@@ -1,33 +1,43 @@
 import React from 'react';
 import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
-import {Location} from 'history';
+import {Location, LocationDescriptor, Query} from 'history';
 import omit from 'lodash/omit';
 
 import {CreateAlertFromViewButton} from 'app/components/createAlertButton';
+import TransactionsList, {DropdownOption} from 'app/components/discover/transactionsList';
 import * as Layout from 'app/components/layouts/thirds';
 import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
+import {t} from 'app/locale';
 import space from 'app/styles/space';
 import {Organization, Project} from 'app/types';
 import {generateQueryWithTag} from 'app/utils';
+import {trackAnalyticsEvent} from 'app/utils/analytics';
+import {TableDataRow} from 'app/utils/discover/discoverQuery';
 import EventView from 'app/utils/discover/eventView';
 import {getAggregateAlias} from 'app/utils/discover/fields';
+import {generateEventSlug} from 'app/utils/discover/urls';
 import {decodeScalar} from 'app/utils/queryString';
+import {stringifyQueryObject, tokenizeSearch} from 'app/utils/tokenizeSearch';
 import withProjects from 'app/utils/withProjects';
 import SearchBar from 'app/views/events/searchBar';
+import {Actions, updateQuery} from 'app/views/eventsV2/table/cellAction';
+import {TableColumn} from 'app/views/eventsV2/table/types';
 import Tags from 'app/views/eventsV2/tags';
 import {
   PERCENTILE as VITAL_PERCENTILE,
   VITAL_GROUPS,
 } from 'app/views/performance/transactionVitals/constants';
 
+import {getTransactionDetailsUrl} from '../utils';
+
 import TransactionSummaryCharts from './charts';
 import TransactionHeader, {Tab} from './header';
 import RelatedIssues from './relatedIssues';
 import SidebarCharts from './sidebarCharts';
 import StatusBreakdown from './statusBreakdown';
-import TransactionList from './transactionList';
 import UserStats from './userStats';
+import {TransactionFilterOptions} from './utils';
 
 type Props = {
   location: Location;
@@ -83,6 +93,58 @@ class SummaryContent extends React.Component<Props, State> {
     this.setState({incompatibleAlertNotice});
   };
 
+  handleCellAction = (column: TableColumn<React.ReactText>) => {
+    return (action: Actions, value: React.ReactText) => {
+      const {eventView, location} = this.props;
+
+      const searchConditions = tokenizeSearch(eventView.query);
+
+      // remove any event.type queries since it is implied to apply to only transactions
+      searchConditions.removeTag('event.type');
+
+      // no need to include transaction as its already in the query params
+      searchConditions.removeTag('transaction');
+
+      updateQuery(searchConditions, action, column.name, value);
+
+      browserHistory.push({
+        pathname: location.pathname,
+        query: {
+          ...location.query,
+          cursor: undefined,
+          query: stringifyQueryObject(searchConditions),
+        },
+      });
+    };
+  };
+
+  handleTransactionsListSortChange = (value: string) => {
+    const {location} = this.props;
+    const target = {
+      pathname: location.pathname,
+      query: {...location.query, showTransactions: value, transactionCursor: undefined},
+    };
+    browserHistory.push(target);
+  };
+
+  handleDiscoverViewClick = () => {
+    const {organization} = this.props;
+    trackAnalyticsEvent({
+      eventKey: 'performance_views.summary.view_in_discover',
+      eventName: 'Performance Views: View in Discover from Transaction Summary',
+      organization_id: parseInt(organization.id, 10),
+    });
+  };
+
+  handleViewDetailsClick = (_e: React.MouseEvent<Element>) => {
+    const {organization} = this.props;
+    trackAnalyticsEvent({
+      eventKey: 'performance_views.summary.view_details',
+      eventName: 'Performance Views: View Details from Transaction Summary',
+      organization_id: parseInt(organization.id, 10),
+    });
+  };
+
   render() {
     const {
       transactionName,
@@ -106,6 +168,10 @@ class SummaryContent extends React.Component<Props, State> {
       })
     );
 
+    const {selectedSort, sortOptions} = getTransactionsListSort(location, {
+      p95: slowDuration,
+    });
+
     return (
       <React.Fragment>
         <TransactionHeader
@@ -136,12 +202,21 @@ class SummaryContent extends React.Component<Props, State> {
               eventView={eventView}
               totalValues={totalCount}
             />
-            <TransactionList
-              organization={organization}
-              transactionName={transactionName}
+            <TransactionsList
               location={location}
+              organization={organization}
               eventView={eventView}
-              slowDuration={slowDuration}
+              selected={selectedSort}
+              options={sortOptions}
+              titles={[t('id'), t('user'), t('duration'), t('timestamp')]}
+              handleDropdownChange={this.handleTransactionsListSortChange}
+              generateLink={{
+                id: generateTransactionLink(transactionName),
+              }}
+              baseline={transactionName}
+              handleBaselineClick={this.handleViewDetailsClick}
+              handleCellAction={this.handleCellAction}
+              handleOpenInDiscoverClick={this.handleDiscoverViewClick}
             />
             <RelatedIssues
               organization={organization}
@@ -179,6 +254,54 @@ class SummaryContent extends React.Component<Props, State> {
   }
 }
 
+function generateTransactionLink(transactionName: string) {
+  return (
+    organization: Organization,
+    tableRow: TableDataRow,
+    query: Query
+  ): LocationDescriptor => {
+    const eventSlug = generateEventSlug(tableRow);
+    return getTransactionDetailsUrl(organization, eventSlug, transactionName, query);
+  };
+}
+
+function getFilterOptions({p95}: {p95: number}): DropdownOption[] {
+  return [
+    {
+      sort: {kind: 'asc', field: 'transaction.duration'},
+      value: TransactionFilterOptions.FASTEST,
+      label: t('Fastest Transactions'),
+    },
+    {
+      query: [['transaction.duration', `<=${p95.toFixed(0)}`]],
+      sort: {kind: 'desc', field: 'transaction.duration'},
+      value: TransactionFilterOptions.SLOW,
+      label: t('Slow Transactions (p95)'),
+    },
+    {
+      sort: {kind: 'desc', field: 'transaction.duration'},
+      value: TransactionFilterOptions.OUTLIER,
+      label: t('Outlier Transactions (p100)'),
+    },
+    {
+      sort: {kind: 'desc', field: 'timestamp'},
+      value: TransactionFilterOptions.RECENT,
+      label: t('Recent Transactions'),
+    },
+  ];
+}
+
+function getTransactionsListSort(
+  location: Location,
+  options: {p95: number}
+): {selectedSort: DropdownOption; sortOptions: DropdownOption[]} {
+  const sortOptions = getFilterOptions(options);
+  const urlParam =
+    decodeScalar(location.query.showTransactions) || TransactionFilterOptions.SLOW;
+  const selectedSort = sortOptions.find(opt => opt.value === urlParam) || sortOptions[0];
+  return {selectedSort, sortOptions};
+}
+
 const StyledSearchBar = styled(SearchBar)`
   margin-bottom: ${space(1)};
 `;

+ 0 - 514
src/sentry/static/sentry/app/views/performance/transactionSummary/transactionList.tsx

@@ -1,514 +0,0 @@
-import React from 'react';
-import {browserHistory} from 'react-router';
-import styled from '@emotion/styled';
-import {Location, Query} from 'history';
-
-import DiscoverButton from 'app/components/discoverButton';
-import DropdownControl, {DropdownItem} from 'app/components/dropdownControl';
-import SortLink from 'app/components/gridEditable/sortLink';
-import Link from 'app/components/links/link';
-import LoadingIndicator from 'app/components/loadingIndicator';
-import Pagination from 'app/components/pagination';
-import PanelTable from 'app/components/panels/panelTable';
-import {t} from 'app/locale';
-import overflowEllipsis from 'app/styles/overflowEllipsis';
-import space from 'app/styles/space';
-import {Organization} from 'app/types';
-import {trackAnalyticsEvent} from 'app/utils/analytics';
-import DiscoverQuery, {TableData, TableDataRow} from 'app/utils/discover/discoverQuery';
-import EventView, {MetaType} from 'app/utils/discover/eventView';
-import {getFieldRenderer} from 'app/utils/discover/fieldRenderers';
-import {getAggregateAlias, Sort} from 'app/utils/discover/fields';
-import {generateEventSlug} from 'app/utils/discover/urls';
-import {getDuration} from 'app/utils/formatters';
-import {decodeScalar} from 'app/utils/queryString';
-import {stringifyQueryObject, tokenizeSearch} from 'app/utils/tokenizeSearch';
-import CellAction, {Actions, updateQuery} from 'app/views/eventsV2/table/cellAction';
-import HeaderCell from 'app/views/eventsV2/table/headerCell';
-import {TableColumn} from 'app/views/eventsV2/table/types';
-
-import {GridCell, GridCellNumber} from '../styles';
-import {getTransactionComparisonUrl, getTransactionDetailsUrl} from '../utils';
-
-import BaselineQuery, {BaselineQueryResults} from './baselineQuery';
-import {TransactionFilterOptions} from './utils';
-
-const TOP_TRANSACTION_LIMIT = 5;
-
-type FilterOption = {
-  query: [string, string][] | null;
-  sort: Sort;
-  value: string;
-  label: string;
-};
-
-function getFilterOptions({p95}: {p95: number}): FilterOption[] {
-  return [
-    {
-      query: null,
-      sort: {kind: 'asc', field: 'transaction.duration'},
-      value: TransactionFilterOptions.FASTEST,
-      label: t('Fastest Transactions'),
-    },
-    {
-      query: [['transaction.duration', `<=${p95.toFixed(0)}`]],
-      sort: {kind: 'desc', field: 'transaction.duration'},
-      value: TransactionFilterOptions.SLOW,
-      label: t('Slow Transactions (p95)'),
-    },
-    {
-      query: null,
-      sort: {kind: 'desc', field: 'transaction.duration'},
-      value: TransactionFilterOptions.OUTLIER,
-      label: t('Outlier Transactions (p100)'),
-    },
-    {
-      query: null,
-      sort: {kind: 'desc', field: 'timestamp'},
-      value: TransactionFilterOptions.RECENT,
-      label: t('Recent Transactions'),
-    },
-  ];
-}
-
-function getTransactionSort(
-  location: Location,
-  p95: number
-): {selected: FilterOption; options: FilterOption[]} {
-  const options = getFilterOptions({p95});
-  const urlParam =
-    decodeScalar(location.query.showTransactions) || TransactionFilterOptions.SLOW;
-  const selected = options.find(opt => opt.value === urlParam) || options[0];
-  return {selected, options};
-}
-
-type WrapperProps = {
-  eventView: EventView;
-  location: Location;
-  organization: Organization;
-  transactionName: string;
-  slowDuration: number | undefined;
-};
-
-class TransactionList extends React.Component<WrapperProps> {
-  handleCursor = (cursor: string, pathname: string, query: Query) => {
-    browserHistory.push({
-      pathname,
-      query: {...query, transactionCursor: cursor},
-    });
-  };
-
-  handleTransactionFilterChange = (value: string) => {
-    const {location, organization} = this.props;
-    trackAnalyticsEvent({
-      eventKey: 'performance_views.summary.filter_transactions',
-      eventName: 'Performance Views: Filter transactions table',
-      organization_id: parseInt(organization.id, 10),
-      value,
-    });
-    const target = {
-      pathname: location.pathname,
-      query: {...location.query, showTransactions: value, transactionCursor: undefined},
-    };
-    browserHistory.push(target);
-  };
-
-  handleDiscoverViewClick = () => {
-    const {organization} = this.props;
-    trackAnalyticsEvent({
-      eventKey: 'performance_views.summary.view_in_discover',
-      eventName: 'Performance Views: View in Discover from Transaction Summary',
-      organization_id: parseInt(organization.id, 10),
-    });
-  };
-
-  renderTable(eventView: EventView) {
-    const {location, organization, transactionName} = this.props;
-    const cursor = decodeScalar(location.query?.transactionCursor);
-
-    if (!organization.features.includes('transaction-comparison')) {
-      return (
-        <DiscoverQuery
-          location={location}
-          eventView={eventView}
-          orgSlug={organization.slug}
-          limit={TOP_TRANSACTION_LIMIT}
-          cursor={cursor}
-        >
-          {({isLoading, tableData, pageLinks}) => (
-            <React.Fragment>
-              <TransactionTable
-                organization={organization}
-                location={location}
-                transactionName={transactionName}
-                eventView={eventView}
-                tableData={tableData}
-                isLoading={isLoading}
-                baselineTransaction={null}
-              />
-              <StyledPagination
-                pageLinks={pageLinks}
-                onCursor={this.handleCursor}
-                size="small"
-              />
-            </React.Fragment>
-          )}
-        </DiscoverQuery>
-      );
-    }
-
-    return (
-      <DiscoverQuery
-        location={location}
-        eventView={eventView}
-        orgSlug={organization.slug}
-        limit={TOP_TRANSACTION_LIMIT}
-        cursor={cursor}
-      >
-        {({isLoading, tableData, pageLinks}) => (
-          <BaselineQuery eventView={eventView} orgSlug={organization.slug}>
-            {baselineQueryProps => {
-              return (
-                <React.Fragment>
-                  <TransactionTable
-                    organization={organization}
-                    location={location}
-                    transactionName={transactionName}
-                    eventView={eventView}
-                    tableData={tableData}
-                    isLoading={isLoading || baselineQueryProps.isLoading}
-                    baselineTransaction={baselineQueryProps.results}
-                  />
-                  <StyledPagination
-                    pageLinks={pageLinks}
-                    onCursor={this.handleCursor}
-                    size="small"
-                  />
-                </React.Fragment>
-              );
-            }}
-          </BaselineQuery>
-        )}
-      </DiscoverQuery>
-    );
-  }
-
-  render() {
-    const {eventView, location, organization, slowDuration} = this.props;
-    const {selected, options} = getTransactionSort(location, slowDuration || 0);
-    const sortedEventView = eventView.withSorts([selected.sort]);
-    if (selected.query) {
-      const query = tokenizeSearch(sortedEventView.query);
-      selected.query.forEach(item => query.setTagValues(item[0], [item[1]]));
-      sortedEventView.query = stringifyQueryObject(query);
-    }
-
-    return (
-      <React.Fragment>
-        <Header>
-          <DropdownControl
-            data-test-id="filter-transactions"
-            label={selected.label}
-            buttonProps={{prefix: t('Filter'), size: 'small'}}
-          >
-            {options.map(({value, label}) => (
-              <DropdownItem
-                key={value}
-                onSelect={this.handleTransactionFilterChange}
-                eventKey={value}
-                isActive={value === selected.value}
-              >
-                {label}
-              </DropdownItem>
-            ))}
-          </DropdownControl>
-          <HeaderButtonContainer>
-            <DiscoverButton
-              onClick={this.handleDiscoverViewClick}
-              to={sortedEventView.getResultsViewUrlTarget(organization.slug)}
-              size="small"
-              data-test-id="discover-open"
-            >
-              {t('Open in Discover')}
-            </DiscoverButton>
-          </HeaderButtonContainer>
-        </Header>
-        {this.renderTable(sortedEventView)}
-      </React.Fragment>
-    );
-  }
-}
-
-type Props = {
-  eventView: EventView;
-  location: Location;
-  organization: Organization;
-  transactionName: string;
-  baselineTransaction: BaselineQueryResults | null;
-
-  isLoading: boolean;
-  tableData: TableData | null | undefined;
-};
-
-class TransactionTable extends React.PureComponent<Props> {
-  handleViewDetailsClick = () => {
-    const {organization} = this.props;
-    trackAnalyticsEvent({
-      eventKey: 'performance_views.summary.view_details',
-      eventName: 'Performance Views: View Details from Transaction Summary',
-      organization_id: parseInt(organization.id, 10),
-    });
-  };
-
-  handleCellAction = (column: TableColumn<React.ReactText>) => {
-    return (action: Actions, value: React.ReactText) => {
-      const {eventView, location} = this.props;
-
-      const searchConditions = tokenizeSearch(eventView.query);
-
-      // remove any event.type queries since it is implied to apply to only transactions
-      searchConditions.removeTag('event.type');
-
-      // no need to include transaction as its already in the query params
-      searchConditions.removeTag('transaction');
-
-      updateQuery(searchConditions, action, column.name, value);
-
-      browserHistory.push({
-        pathname: location.pathname,
-        query: {
-          ...location.query,
-          cursor: undefined,
-          query: stringifyQueryObject(searchConditions),
-        },
-      });
-    };
-  };
-
-  renderHeader() {
-    const {eventView, tableData, organization} = this.props;
-
-    const tableMeta = tableData?.meta;
-    const columnOrder = eventView.getColumns();
-    const generateSortLink = () => undefined;
-    const titles = [t('id'), t('user'), t('duration'), t('timestamp')];
-
-    const headerColumns = titles.map((title, index) => (
-      <HeaderCell column={columnOrder[index]} tableMeta={tableMeta} key={index}>
-        {({align}) => {
-          return (
-            <HeadCellContainer>
-              <SortLink
-                align={align}
-                title={title}
-                direction={undefined}
-                canSort={false}
-                generateSortLink={generateSortLink}
-              />
-            </HeadCellContainer>
-          );
-        }}
-      </HeaderCell>
-    ));
-
-    // add baseline transaction column
-
-    if (organization.features.includes('transaction-comparison')) {
-      headerColumns.push(
-        <HeadCellContainer key="baseline">
-          <SortLink
-            align="right"
-            title={t('Compared to Baseline')}
-            direction={undefined}
-            canSort={false}
-            generateSortLink={generateSortLink}
-          />
-        </HeadCellContainer>
-      );
-    }
-
-    return headerColumns;
-  }
-
-  renderResults() {
-    const {isLoading, tableData} = this.props;
-    let cells: React.ReactNode[] = [];
-
-    if (isLoading) {
-      return cells;
-    }
-    if (!tableData || !tableData.meta || !tableData.data) {
-      return cells;
-    }
-
-    const columnOrder = this.props.eventView.getColumns();
-
-    tableData.data.forEach((row, i: number) => {
-      // Another check to appease tsc
-      if (!tableData.meta) {
-        return;
-      }
-      cells = cells.concat(this.renderRow(row, i, columnOrder, tableData.meta));
-    });
-    return cells;
-  }
-
-  renderRow(
-    row: TableDataRow,
-    rowIndex: number,
-    columnOrder: TableColumn<React.ReactText>[],
-    tableMeta: MetaType
-  ) {
-    const {organization, location, transactionName, baselineTransaction} = this.props;
-
-    const resultsRow = columnOrder.map((column, index) => {
-      const field = String(column.key);
-      // TODO add a better abstraction for this in fieldRenderers.
-      const fieldName = getAggregateAlias(field);
-      const fieldType = tableMeta[fieldName];
-
-      const fieldRenderer = getFieldRenderer(field, tableMeta);
-      let rendered = fieldRenderer(row, {organization, location});
-
-      const isFirstCell = index === 0;
-
-      if (isFirstCell) {
-        // The first column of the row should link to the transaction details view
-        const eventSlug = generateEventSlug(row);
-        const target = getTransactionDetailsUrl(
-          organization,
-          eventSlug,
-          transactionName,
-          location.query
-        );
-
-        rendered = (
-          <Link
-            data-test-id="view-details"
-            to={target}
-            onClick={this.handleViewDetailsClick}
-          >
-            {rendered}
-          </Link>
-        );
-      }
-
-      const isNumeric = ['integer', 'number', 'duration'].includes(fieldType);
-      const key = `${rowIndex}:${column.key}:${index}`;
-
-      return (
-        <BodyCellContainer key={key}>
-          <CellAction
-            column={column}
-            dataRow={row}
-            handleCellAction={this.handleCellAction(column)}
-          >
-            {isNumeric ? (
-              <GridCellNumber>{rendered}</GridCellNumber>
-            ) : (
-              <GridCell>{rendered}</GridCell>
-            )}
-          </CellAction>
-        </BodyCellContainer>
-      );
-    });
-
-    // add baseline transaction column
-
-    if (organization.features.includes('transaction-comparison')) {
-      if (baselineTransaction) {
-        const currentTransactionDuration: number =
-          Number(row['transaction.duration']) || 0;
-
-        const delta = Math.abs(
-          currentTransactionDuration - baselineTransaction['transaction.duration']
-        );
-
-        const relativeSpeed =
-          currentTransactionDuration < baselineTransaction['transaction.duration']
-            ? t('faster')
-            : currentTransactionDuration > baselineTransaction['transaction.duration']
-            ? t('slower')
-            : '';
-
-        const target = getTransactionComparisonUrl({
-          organization,
-          baselineEventSlug: generateEventSlug(baselineTransaction),
-          regressionEventSlug: generateEventSlug(row),
-          transaction: transactionName,
-          query: location.query,
-        });
-
-        resultsRow.push(
-          <BodyCellContainer key={`${rowIndex}-baseline`} style={{textAlign: 'right'}}>
-            <GridCell>
-              <Link to={target} onClick={this.handleViewDetailsClick}>
-                {`${getDuration(delta / 1000, delta < 1000 ? 0 : 2)} ${relativeSpeed}`}
-              </Link>
-            </GridCell>
-          </BodyCellContainer>
-        );
-      } else {
-        resultsRow.push(
-          <BodyCellContainer key={`${rowIndex}-baseline`}>-</BodyCellContainer>
-        );
-      }
-    }
-
-    return resultsRow;
-  }
-
-  render() {
-    const {isLoading, tableData} = this.props;
-
-    const hasResults =
-      tableData && tableData.data && tableData.meta && tableData.data.length > 0;
-
-    // Custom set the height so we don't have layout shift when results are loaded.
-    const loader = <LoadingIndicator style={{margin: '70px auto'}} />;
-
-    return (
-      <StyledPanelTable
-        isEmpty={!hasResults}
-        emptyMessage={t('No transactions found')}
-        headers={this.renderHeader()}
-        isLoading={isLoading}
-        disablePadding
-        loader={loader}
-      >
-        {this.renderResults()}
-      </StyledPanelTable>
-    );
-  }
-}
-
-const StyledPanelTable = styled(PanelTable)`
-  margin-bottom: ${space(1)};
-`;
-
-const Header = styled('div')`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin: 0 0 ${space(1)} 0;
-`;
-
-const HeaderButtonContainer = styled('div')`
-  display: flex;
-  flex-direction: row;
-`;
-
-const HeadCellContainer = styled('div')`
-  padding: ${space(2)};
-`;
-
-const BodyCellContainer = styled('div')`
-  padding: ${space(1)} ${space(2)};
-  ${overflowEllipsis};
-`;
-
-const StyledPagination = styled(Pagination)`
-  margin: 0 0 ${space(3)} 0;
-`;
-
-export default TransactionList;

+ 1 - 1
tests/acceptance/test_performance_summary.py

@@ -82,7 +82,7 @@ class PerformanceSummaryTest(AcceptanceTestCase, SnubaTestCase):
             self.page.wait_until_loaded()
 
             # View the first event details.
-            self.browser.element('[data-test-id="view-details"]').click()
+            self.browser.element('[data-test-id="view-id"]').click()
             self.page.wait_until_loaded()
             self.browser.snapshot("performance event details")
 

+ 322 - 241
tests/js/spec/components/discover/transactionsList.spec.jsx

@@ -18,7 +18,12 @@ describe('TransactionsList', function () {
   let eventView;
   let options;
   let handleDropdownChange;
-  let generateLink;
+
+  const initialize = (config = {}) => {
+    context = initializeOrg(config);
+    organization = context.organization;
+    project = context.project;
+  };
 
   beforeEach(function () {
     api = new Client();
@@ -26,270 +31,346 @@ describe('TransactionsList', function () {
       pathname: '/',
       query: {},
     };
-    context = initializeOrg();
-    organization = context.organization;
-    project = context.project;
-    eventView = EventView.fromSavedQuery({
-      id: '',
-      name: 'test query',
-      version: 2,
-      fields: ['transaction', 'count()'],
-      projects: [project.id],
-    });
-    options = [
-      {
-        sort: {kind: 'asc', field: 'transaction'},
-        value: 'name',
-        label: t('Transactions'),
-      },
-      {
-        sort: {kind: 'desc', field: 'count'},
-        value: 'count',
-        label: t('Failing Transactions'),
-      },
-    ];
     handleDropdownChange = value => {
       const selected = options.find(option => option.value === value);
       if (selected) {
         wrapper.setProps({selected});
       }
     };
-    generateLink = {
-      transaction: (org, row, query) => ({
-        pathname: `/${org.slug}`,
-        query: {
-          ...query,
-          transaction: row.transaction,
-          count: row.count,
+  });
+
+  describe('Basic', function () {
+    let generateLink;
+
+    beforeEach(function () {
+      initialize();
+      eventView = EventView.fromSavedQuery({
+        id: '',
+        name: 'test query',
+        version: 2,
+        fields: ['transaction', 'count()'],
+        projects: [project.id],
+      });
+      options = [
+        {
+          sort: {kind: 'asc', field: 'transaction'},
+          value: 'name',
+          label: t('Transactions'),
         },
-      }),
-    };
+        {
+          sort: {kind: 'desc', field: 'count'},
+          value: 'count',
+          label: t('Failing Transactions'),
+        },
+      ];
+      generateLink = {
+        transaction: (org, row, query) => ({
+          pathname: `/${org.slug}`,
+          query: {
+            ...query,
+            transaction: row.transaction,
+            count: row.count,
+          },
+        }),
+      };
 
-    MockApiClient.addMockResponse(
-      {
-        url: `/organizations/${organization.slug}/eventsv2/`,
-        body: {
-          meta: {transaction: 'string', count: 'number'},
-          data: [
-            {transaction: '/a', count: 100},
-            {transaction: '/b', count: 1000},
-          ],
+      MockApiClient.addMockResponse(
+        {
+          url: `/organizations/${organization.slug}/eventsv2/`,
+          body: {
+            meta: {transaction: 'string', count: 'number'},
+            data: [
+              {transaction: '/a', count: 100},
+              {transaction: '/b', count: 1000},
+            ],
+          },
         },
-      },
-      {
-        predicate: (_, opts) => opts && opts.query && opts.query.sort === 'transaction',
-      }
-    );
-    MockApiClient.addMockResponse(
-      {
-        url: `/organizations/${organization.slug}/eventsv2/`,
+        {
+          predicate: (_, opts) => opts && opts.query && opts.query.sort === 'transaction',
+        }
+      );
+      MockApiClient.addMockResponse(
+        {
+          url: `/organizations/${organization.slug}/eventsv2/`,
+          body: {
+            meta: {transaction: 'string', count: 'number'},
+            data: [
+              {transaction: '/b', count: 1000},
+              {transaction: '/a', count: 100},
+            ],
+          },
+        },
+        {
+          predicate: (_, opts) => opts && opts.query && opts.query.sort === '-count',
+        }
+      );
+      MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/events-trends/`,
         body: {
-          meta: {transaction: 'string', count: 'number'},
+          meta: {
+            transaction: 'string',
+            trend_percentage: 'percentage',
+            trend_difference: 'number',
+          },
           data: [
-            {transaction: '/b', count: 1000},
-            {transaction: '/a', count: 100},
+            {transaction: '/a', 'trend_percentage()': 1.25, 'trend_difference()': 25},
+            {transaction: '/b', 'trend_percentage()': 1.05, 'trend_difference()': 5},
           ],
         },
-      },
-      {
-        predicate: (_, opts) => opts && opts.query && opts.query.sort === '-count',
-      }
-    );
-    MockApiClient.addMockResponse({
-      url: `/organizations/${organization.slug}/events-trends/`,
-      body: {
-        meta: {
-          transaction: 'string',
-          trend_percentage: 'percentage',
-          trend_difference: 'number',
-        },
-        data: [
-          {transaction: '/a', 'trend_percentage()': 1.25, 'trend_difference()': 25},
-          {transaction: '/b', 'trend_percentage()': 1.05, 'trend_difference()': 5},
-        ],
-      },
+      });
     });
-  });
 
-  const selectDropdownOption = (w, selection) => {
-    w.find('DropdownControl').first().simulate('click');
-    w.find(`DropdownItem[data-test-id="option-${selection}"] span`).simulate('click');
-  };
+    const selectDropdownOption = (w, selection) => {
+      w.find('DropdownControl').first().simulate('click');
+      w.find(`DropdownItem[data-test-id="option-${selection}"] span`).simulate('click');
+    };
 
-  it('renders basic UI components', async function () {
-    wrapper = mountWithTheme(
-      <TransactionsList
-        api={api}
-        location={location}
-        organization={organization}
-        eventView={eventView}
-        selected={options[0]}
-        options={options}
-        handleDropdownChange={handleDropdownChange}
-      />
-    );
-
-    await tick();
-    wrapper.update();
-
-    expect(wrapper.find('DropdownControl')).toHaveLength(1);
-    expect(wrapper.find('DropdownItem')).toHaveLength(2);
-    expect(wrapper.find('DiscoverButton')).toHaveLength(1);
-    expect(wrapper.find('Pagination')).toHaveLength(1);
-    expect(wrapper.find('PanelTable')).toHaveLength(1);
-    // 2 for the transaction names
-    expect(wrapper.find('GridCell')).toHaveLength(2);
-    // 2 for the counts
-    expect(wrapper.find('GridCellNumber')).toHaveLength(2);
-  });
+    it('renders basic UI components', async function () {
+      wrapper = mountWithTheme(
+        <TransactionsList
+          api={api}
+          location={location}
+          organization={organization}
+          eventView={eventView}
+          selected={options[0]}
+          options={options}
+          handleDropdownChange={handleDropdownChange}
+        />
+      );
+
+      await tick();
+      wrapper.update();
 
-  it('renders a trend view', async function () {
-    options.push({
-      sort: {kind: 'desc', field: 'trend_percentage()'},
-      value: 'regression',
-      label: t('Trending Regressions'),
-      trendType: 'regression',
+      expect(wrapper.find('DropdownControl')).toHaveLength(1);
+      expect(wrapper.find('DropdownItem')).toHaveLength(2);
+      expect(wrapper.find('DiscoverButton')).toHaveLength(1);
+      expect(wrapper.find('Pagination')).toHaveLength(1);
+      expect(wrapper.find('PanelTable')).toHaveLength(1);
+      // 2 for the transaction names
+      expect(wrapper.find('GridCell')).toHaveLength(2);
+      // 2 for the counts
+      expect(wrapper.find('GridCellNumber')).toHaveLength(2);
     });
-    wrapper = mountWithTheme(
-      <TransactionsList
-        api={api}
-        location={location}
-        organization={organization}
-        trendView={eventView}
-        selected={options[2]}
-        options={options}
-        handleDropdownChange={handleDropdownChange}
-      />
-    );
-
-    await tick();
-    wrapper.update();
-
-    expect(wrapper.find('DropdownControl')).toHaveLength(1);
-    expect(wrapper.find('DropdownItem')).toHaveLength(3);
-    expect(wrapper.find('DiscoverButton')).toHaveLength(0);
-    expect(wrapper.find('Pagination')).toHaveLength(1);
-    expect(wrapper.find('PanelTable')).toHaveLength(1);
-    // trend_percentage and transaction name
-    expect(wrapper.find('GridCell')).toHaveLength(4);
-    // trend_difference
-    expect(wrapper.find('GridCellNumber')).toHaveLength(2);
-  });
 
-  it('renders default titles', async function () {
-    wrapper = mountWithTheme(
-      <TransactionsList
-        api={api}
-        location={location}
-        organization={organization}
-        eventView={eventView}
-        selected={options[0]}
-        options={options}
-        handleDropdownChange={handleDropdownChange}
-      />
-    );
-
-    await tick();
-    wrapper.update();
-
-    const headers = wrapper.find('HeaderCell');
-    expect(headers).toHaveLength(2);
-    expect(headers.first().text()).toEqual('transaction');
-    expect(headers.last().text()).toEqual('count()');
-  });
+    it('renders a trend view', async function () {
+      options.push({
+        sort: {kind: 'desc', field: 'trend_percentage()'},
+        value: 'regression',
+        label: t('Trending Regressions'),
+        trendType: 'regression',
+      });
+      wrapper = mountWithTheme(
+        <TransactionsList
+          api={api}
+          location={location}
+          organization={organization}
+          trendView={eventView}
+          selected={options[2]}
+          options={options}
+          handleDropdownChange={handleDropdownChange}
+        />
+      );
 
-  it('renders custom titles', async function () {
-    wrapper = mountWithTheme(
-      <TransactionsList
-        api={api}
-        location={location}
-        organization={organization}
-        eventView={eventView}
-        selected={options[0]}
-        options={options}
-        handleDropdownChange={handleDropdownChange}
-        titles={['foo', 'bar']}
-      />
-    );
-
-    await tick();
-    wrapper.update();
-
-    const headers = wrapper.find('HeaderCell');
-    expect(headers).toHaveLength(2);
-    expect(headers.first().text()).toEqual('foo');
-    expect(headers.last().text()).toEqual('bar');
-  });
+      await tick();
+      wrapper.update();
+
+      expect(wrapper.find('DropdownControl')).toHaveLength(1);
+      expect(wrapper.find('DropdownItem')).toHaveLength(3);
+      expect(wrapper.find('DiscoverButton')).toHaveLength(0);
+      expect(wrapper.find('Pagination')).toHaveLength(1);
+      expect(wrapper.find('PanelTable')).toHaveLength(1);
+      // trend_percentage and transaction name
+      expect(wrapper.find('GridCell')).toHaveLength(4);
+      // trend_difference
+      expect(wrapper.find('GridCellNumber')).toHaveLength(2);
+    });
 
-  it('allows users to change the sort in the dropdown', async function () {
-    wrapper = mountWithTheme(
-      <TransactionsList
-        api={api}
-        location={location}
-        organization={organization}
-        eventView={eventView}
-        selected={options[0]}
-        options={options}
-        handleDropdownChange={handleDropdownChange}
-      />
-    );
-
-    await tick();
-    wrapper.update();
-
-    // initial sort is ascending by transaction name
-    expect(wrapper.find('GridCell').first().text()).toEqual('/a');
-    expect(wrapper.find('GridCellNumber').first().text()).toEqual('100');
-    expect(wrapper.find('GridCell').last().text()).toEqual('/b');
-    expect(wrapper.find('GridCellNumber').last().text()).toEqual('1000');
-
-    selectDropdownOption(wrapper, 'count');
-    await tick();
-    wrapper.update();
-
-    // now the sort is descending by count
-    expect(wrapper.find('GridCell').first().text()).toEqual('/b');
-    expect(wrapper.find('GridCellNumber').first().text()).toEqual('1000');
-    expect(wrapper.find('GridCell').last().text()).toEqual('/a');
-    expect(wrapper.find('GridCellNumber').last().text()).toEqual('100');
+    it('renders default titles', async function () {
+      wrapper = mountWithTheme(
+        <TransactionsList
+          api={api}
+          location={location}
+          organization={organization}
+          eventView={eventView}
+          selected={options[0]}
+          options={options}
+          handleDropdownChange={handleDropdownChange}
+        />
+      );
+
+      await tick();
+      wrapper.update();
+
+      const headers = wrapper.find('SortLink');
+      expect(headers).toHaveLength(2);
+      expect(headers.first().text()).toEqual('transaction');
+      expect(headers.last().text()).toEqual('count()');
+    });
+
+    it('renders custom titles', async function () {
+      wrapper = mountWithTheme(
+        <TransactionsList
+          api={api}
+          location={location}
+          organization={organization}
+          eventView={eventView}
+          selected={options[0]}
+          options={options}
+          handleDropdownChange={handleDropdownChange}
+          titles={['foo', 'bar']}
+        />
+      );
+
+      await tick();
+      wrapper.update();
+
+      const headers = wrapper.find('SortLink');
+      expect(headers).toHaveLength(2);
+      expect(headers.first().text()).toEqual('foo');
+      expect(headers.last().text()).toEqual('bar');
+    });
+
+    it('allows users to change the sort in the dropdown', async function () {
+      wrapper = mountWithTheme(
+        <TransactionsList
+          api={api}
+          location={location}
+          organization={organization}
+          eventView={eventView}
+          selected={options[0]}
+          options={options}
+          handleDropdownChange={handleDropdownChange}
+        />
+      );
+
+      await tick();
+      wrapper.update();
+
+      // initial sort is ascending by transaction name
+      expect(wrapper.find('GridCell').first().text()).toEqual('/a');
+      expect(wrapper.find('GridCellNumber').first().text()).toEqual('100');
+      expect(wrapper.find('GridCell').last().text()).toEqual('/b');
+      expect(wrapper.find('GridCellNumber').last().text()).toEqual('1000');
+
+      selectDropdownOption(wrapper, 'count');
+      await tick();
+      wrapper.update();
+
+      // now the sort is descending by count
+      expect(wrapper.find('GridCell').first().text()).toEqual('/b');
+      expect(wrapper.find('GridCellNumber').first().text()).toEqual('1000');
+      expect(wrapper.find('GridCell').last().text()).toEqual('/a');
+      expect(wrapper.find('GridCellNumber').last().text()).toEqual('100');
+    });
+
+    it('generates link for the transaction cell', async function () {
+      wrapper = mountWithTheme(
+        <TransactionsList
+          api={api}
+          location={location}
+          organization={organization}
+          eventView={eventView}
+          selected={options[0]}
+          options={options}
+          handleDropdownChange={handleDropdownChange}
+          generateLink={generateLink}
+        />
+      );
+
+      await tick();
+      wrapper.update();
+
+      const links = wrapper.find('Link');
+      expect(links).toHaveLength(2);
+      expect(links.first().props().to).toEqual(
+        expect.objectContaining({
+          pathname: `/${organization.slug}`,
+          query: {
+            transaction: '/a',
+            count: 100,
+          },
+        })
+      );
+      expect(links.last().props().to).toEqual(
+        expect.objectContaining({
+          pathname: `/${organization.slug}`,
+          query: {
+            transaction: '/b',
+            count: 1000,
+          },
+        })
+      );
+    });
   });
 
-  it('generates link for the transaction cell', async function () {
-    wrapper = mountWithTheme(
-      <TransactionsList
-        api={api}
-        location={location}
-        organization={organization}
-        eventView={eventView}
-        selected={options[0]}
-        options={options}
-        handleDropdownChange={handleDropdownChange}
-        generateLink={generateLink}
-      />
-    );
-
-    await tick();
-    wrapper.update();
-
-    const links = wrapper.find('Link');
-    expect(links).toHaveLength(2);
-    expect(links.first().props().to).toEqual(
-      expect.objectContaining({
-        pathname: `/${organization.slug}`,
-        query: {
-          transaction: '/a',
-          count: 100,
+  describe('Baseline', function () {
+    beforeEach(function () {
+      initialize({
+        organization: {features: 'transaction-comparison'},
+      });
+      eventView = EventView.fromSavedQuery({
+        id: '',
+        name: 'baseline query',
+        version: 2,
+        fields: ['id', 'transaction.duration'],
+        projects: [project.id],
+      });
+      options = [
+        {
+          sort: {kind: 'desc', field: 'transaction.duration'},
+          value: 'slow',
+          label: t('Slow Transactions'),
         },
-      })
-    );
-    expect(links.last().props().to).toEqual(
-      expect.objectContaining({
-        pathname: `/${organization.slug}`,
-        query: {
-          transaction: '/b',
-          count: 1000,
+      ];
+
+      MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/eventsv2/`,
+        body: {
+          meta: {id: 'string', 'transaction.duration': 'duration'},
+          data: [
+            {id: 'a', 'transaction.duration': 123},
+            {id: 'c', 'transaction.duration': 12345},
+          ],
         },
-      })
-    );
+      });
+      MockApiClient.addMockResponse({
+        url: `/organizations/${organization.slug}/event-baseline/`,
+        body: {
+          'transaction.duration': 1234,
+        },
+      });
+    });
+
+    it('renders baseline comparison correctly', async function () {
+      wrapper = mountWithTheme(
+        <TransactionsList
+          api={api}
+          location={location}
+          organization={organization}
+          eventView={eventView}
+          selected={options[0]}
+          options={options}
+          handleDropdownChange={handleDropdownChange}
+          baseline="/"
+        />
+      );
+
+      await tick();
+      wrapper.update();
+
+      const titles = ['id', 'transaction.duration', 'Compared to Baseline'];
+      const headers = wrapper.find('SortLink');
+      expect(headers).toHaveLength(titles.length);
+      headers.forEach((header, i) => {
+        expect(header.text()).toEqual(titles[i]);
+      });
+
+      const cellTexts = ['1.11 seconds faster', '11.11 seconds slower'];
+      const cells = wrapper.find('BodyCellContainer[data-test-id="baseline-cell"]');
+      expect(cells).toHaveLength(2);
+      cells.forEach((cell, i) => {
+        expect(cell.text()).toEqual(cellTexts[i]);
+      });
+    });
   });
 });