Browse Source

feat(performance): Cell action to set Transaction thresholds (#27132)

Add a cell action on user misery to set transaction-level
thresholds.
Shruthi 3 years ago
parent
commit
632ef3d712

+ 2 - 0
static/app/utils/discover/discoverQuery.tsx

@@ -23,6 +23,7 @@ export type TableData = {
 };
 
 export type DiscoverQueryPropsWithThresholds = DiscoverQueryProps & {
+  transactionName?: string;
   transactionThreshold?: number;
   transactionThresholdMetric?: TransactionThresholdMetric;
 };
@@ -32,6 +33,7 @@ function shouldRefetchData(
   nextProps: DiscoverQueryPropsWithThresholds
 ) {
   return (
+    prevProps.transactionName !== nextProps.transactionName ||
     prevProps.transactionThreshold !== nextProps.transactionThreshold ||
     prevProps.transactionThresholdMetric !== nextProps.transactionThresholdMetric
   );

+ 22 - 1
static/app/views/eventsV2/table/cellAction.tsx

@@ -6,8 +6,9 @@ import color from 'color';
 import * as PopperJS from 'popper.js';
 
 import {IconEllipsis} from 'app/icons';
-import {t} from 'app/locale';
+import {t, tct} from 'app/locale';
 import space from 'app/styles/space';
+import {defined} from 'app/utils';
 import {TableDataRow} from 'app/utils/discover/discoverQuery';
 import {
   getAggregateAlias,
@@ -27,6 +28,7 @@ export enum Actions {
   TRANSACTION = 'transaction',
   RELEASE = 'release',
   DRILLDOWN = 'drilldown',
+  EDIT_THRESHOLD = 'edit_threshold',
 }
 
 export function updateQuery(
@@ -327,6 +329,25 @@ class CellAction extends React.Component<Props, State> {
       );
     }
 
+    if (
+      column.column.kind === 'function' &&
+      column.column.function[0] === 'user_misery' &&
+      defined(dataRow.project_threshold_config)
+    ) {
+      addMenuItem(
+        Actions.EDIT_THRESHOLD,
+        <ActionItem
+          key="edit_threshold"
+          data-test-id="edit-threshold"
+          onClick={() => handleCellAction(Actions.EDIT_THRESHOLD, value)}
+        >
+          {tct('Edit threshold ([threshold]ms)', {
+            threshold: dataRow.project_threshold_config[1],
+          })}
+        </ActionItem>
+      );
+    }
+
     if (actions.length === 0) {
       return null;
     }

+ 59 - 6
static/app/views/performance/table.tsx

@@ -2,6 +2,7 @@ import * as React from 'react';
 import * as ReactRouter from 'react-router';
 import {Location, LocationDescriptorObject} from 'history';
 
+import {openModal} from 'app/actionCreators/modal';
 import {fetchLegacyKeyTransactionsCount} from 'app/actionCreators/performance';
 import GuideAnchor from 'app/components/assistant/guideAnchor';
 import GridEditable, {COL_WIDTH_UNDEFINED, GridColumn} from 'app/components/gridEditable';
@@ -21,6 +22,10 @@ import {tokenizeSearch} from 'app/utils/tokenizeSearch';
 import CellAction, {Actions, updateQuery} from 'app/views/eventsV2/table/cellAction';
 import {TableColumn} from 'app/views/eventsV2/table/types';
 
+import TransactionThresholdModal, {
+  modalCss,
+  TransactionThresholdMetric,
+} from './transactionSummary/transactionThresholdModal';
 import {transactionSummaryRouteWithQuery} from './transactionSummary/utils';
 import {COLUMN_TITLES} from './data';
 
@@ -57,11 +62,17 @@ type Props = {
 type State = {
   widths: number[];
   keyedTransactions: number | null;
+  transaction: string | undefined;
+  transactionThreshold: number | undefined;
+  transactionThresholdMetric: TransactionThresholdMetric | undefined;
 };
 class Table extends React.Component<Props, State> {
   state: State = {
     widths: [],
     keyedTransactions: null,
+    transaction: undefined,
+    transactionThreshold: undefined,
+    transactionThresholdMetric: undefined,
   };
 
   componentDidMount() {
@@ -78,9 +89,9 @@ class Table extends React.Component<Props, State> {
     }
   }
 
-  handleCellAction = (column: TableColumn<keyof TableDataRow>) => {
+  handleCellAction = (column: TableColumn<keyof TableDataRow>, dataRow: TableDataRow) => {
     return (action: Actions, value: React.ReactText) => {
-      const {eventView, location, organization} = this.props;
+      const {eventView, location, organization, projects} = this.props;
 
       trackAnalyticsEvent({
         eventKey: 'performance_views.overview.cellaction',
@@ -89,6 +100,40 @@ class Table extends React.Component<Props, State> {
         action,
       });
 
+      if (action === Actions.EDIT_THRESHOLD) {
+        const project_threshold = dataRow.project_threshold_config;
+        const transactionName = dataRow.transaction as string;
+        const projectID = getProjectID(dataRow, projects);
+
+        openModal(
+          modalProps => (
+            <TransactionThresholdModal
+              {...modalProps}
+              organization={organization}
+              transactionName={transactionName}
+              eventView={eventView}
+              project={projectID}
+              transactionThreshold={project_threshold[1]}
+              transactionThresholdMetric={project_threshold[0]}
+              onApply={(threshold, metric) => {
+                if (
+                  threshold !== project_threshold[1] ||
+                  metric !== project_threshold[0]
+                ) {
+                  this.setState({
+                    transaction: transactionName,
+                    transactionThreshold: threshold,
+                    transactionThresholdMetric: metric,
+                  });
+                }
+              }}
+            />
+          ),
+          {modalCss, backdrop: 'static'}
+        );
+        return;
+      }
+
       const searchConditions = tokenizeSearch(eventView.query);
 
       // remove any event.type queries since it is implied to apply to only transactions
@@ -130,6 +175,10 @@ class Table extends React.Component<Props, State> {
       Actions.SHOW_LESS_THAN,
     ];
 
+    if (organization.features.includes('project-transaction-threshold-override')) {
+      allowActions.push(Actions.EDIT_THRESHOLD);
+    }
+
     if (field === 'transaction') {
       const projectID = getProjectID(dataRow, projects);
       const summaryView = eventView.clone();
@@ -150,7 +199,7 @@ class Table extends React.Component<Props, State> {
         <CellAction
           column={column}
           dataRow={dataRow}
-          handleCellAction={this.handleCellAction(column)}
+          handleCellAction={this.handleCellAction(column, dataRow)}
           allowActions={allowActions}
         >
           <Link to={target} onClick={this.handleSummaryClick}>
@@ -182,7 +231,7 @@ class Table extends React.Component<Props, State> {
           <CellAction
             column={column}
             dataRow={dataRow}
-            handleCellAction={this.handleCellAction(column)}
+            handleCellAction={this.handleCellAction(column, dataRow)}
             allowActions={allowActions}
           >
             {rendered}
@@ -195,7 +244,7 @@ class Table extends React.Component<Props, State> {
       <CellAction
         column={column}
         dataRow={dataRow}
-        handleCellAction={this.handleCellAction(column)}
+        handleCellAction={this.handleCellAction(column, dataRow)}
         allowActions={allowActions}
       >
         {rendered}
@@ -370,7 +419,8 @@ class Table extends React.Component<Props, State> {
   render() {
     const {eventView, organization, location, setError} = this.props;
 
-    const {widths} = this.state;
+    const {widths, transaction, transactionThreshold, transactionThresholdMetric} =
+      this.state;
     const columnOrder = eventView
       .getColumns()
       // remove key_transactions from the column order as we'll be rendering it
@@ -402,6 +452,9 @@ class Table extends React.Component<Props, State> {
           location={location}
           setError={setError}
           referrer="api.performance.landing-table"
+          transactionName={transaction}
+          transactionThreshold={transactionThreshold}
+          transactionThresholdMetric={transactionThresholdMetric}
         >
           {({pageLinks, isLoading, tableData}) => (
             <React.Fragment>

+ 10 - 5
static/app/views/performance/transactionSummary/transactionThresholdModal.tsx

@@ -36,6 +36,7 @@ type Props = {
   organization: Organization;
   transactionName: string;
   onApply?: (threshold, metric) => void;
+  project?: string;
   projects: Project[];
   eventView: EventView;
   transactionThreshold: number | undefined;
@@ -56,11 +57,14 @@ class TransactionThresholdModal extends React.Component<Props, State> {
   };
 
   getProject() {
-    const {projects, eventView} = this.props;
-    const projectId = String(eventView.project[0]);
-    const project = projects.find(proj => proj.id === projectId);
+    const {projects, eventView, project} = this.props;
 
-    return project;
+    if (defined(project)) {
+      return projects.find(proj => proj.id === project);
+    } else {
+      const projectId = String(eventView.project[0]);
+      return projects.find(proj => proj.id === projectId);
+    }
   }
 
   handleApply = async (event: React.FormEvent) => {
@@ -98,7 +102,8 @@ class TransactionThresholdModal extends React.Component<Props, State> {
         this.setState({
           error: err,
         });
-        const errorMessage = err.responseJSON?.threshold ?? null;
+        const errorMessage =
+          err.responseJSON?.threshold ?? err.responseJSON?.non_field_errors ?? null;
         addErrorMessage(errorMessage);
       });
   };

+ 229 - 0
tests/js/spec/views/performance/table.spec.jsx

@@ -0,0 +1,229 @@
+import {browserHistory} from 'react-router';
+
+import {mountWithTheme} from 'sentry-test/enzyme';
+import {initializeOrg} from 'sentry-test/initializeOrg';
+
+import ProjectsStore from 'app/stores/projectsStore';
+import EventView from 'app/utils/discover/eventView';
+import Table from 'app/views/performance/table';
+
+const FEATURES = ['performance-view'];
+
+function initializeData(projects, query, features = FEATURES) {
+  const organization = TestStubs.Organization({
+    features,
+    projects,
+  });
+  const initialData = initializeOrg({
+    organization,
+    router: {
+      location: {
+        query: query || {},
+      },
+    },
+  });
+  ProjectsStore.loadInitialData(initialData.organization.projects);
+  return initialData;
+}
+
+function openContextMenu(wrapper, cellIndex) {
+  const menu = wrapper.find('CellAction').at(cellIndex);
+  // Hover over the menu
+  menu.find('Container > div').at(0).simulate('mouseEnter');
+  wrapper.update();
+
+  // Open the menu
+  wrapper.find('MenuButton').simulate('click');
+
+  // Return the menu wrapper so we can interact with it.
+  return wrapper.find('CellAction').at(cellIndex).find('Menu');
+}
+
+describe('Performance > Table', function () {
+  const project1 = TestStubs.Project();
+  const project2 = TestStubs.Project();
+  const projects = [project1, project2];
+  const eventView = new EventView({
+    id: '1',
+    name: 'my query',
+    fields: [
+      {
+        field: 'team_key_transaction',
+      },
+      {
+        field: 'transaction',
+      },
+      {
+        field: 'project',
+      },
+      {
+        field: 'tpm()',
+      },
+      {
+        field: 'p50()',
+      },
+      {
+        field: 'p95()',
+      },
+      {
+        field: 'failure_rate()',
+      },
+      {
+        field: 'apdex()',
+      },
+      {
+        field: 'count_unique(user)',
+      },
+      {
+        field: 'count_miserable(user)',
+      },
+      {
+        field: 'user_misery()',
+      },
+    ],
+    sorts: [{field: 'tpm  ', kind: 'desc'}],
+    query: '',
+    project: [project1.id, project2.id],
+    start: '2019-10-01T00:00:00',
+    end: '2019-10-02T00:00:00',
+    statsPeriod: '14d',
+    environment: [],
+  });
+  beforeEach(function () {
+    browserHistory.push = jest.fn();
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/eventsv2/',
+      body: {
+        meta: {
+          user: 'string',
+          transaction: 'string',
+          project: 'string',
+          tpm: 'number',
+          p50: 'number',
+          p95: 'number',
+          failure_rate: 'number',
+          apdex: 'number',
+          count_unique_user: 'number',
+          count_miserable_user: 'number',
+          user_misery: 'number',
+        },
+        data: [
+          {
+            key_transaction: 1,
+            transaction: '/apple/cart',
+            project: project1.slug,
+            user: 'uhoh@example.com',
+            tpm: 30,
+            p50: 100,
+            p95: 500,
+            failure_rate: 0.1,
+            apdex: 0.6,
+            count_unique_user: 1000,
+            count_miserable_user: 122,
+            user_misery: 0.114,
+            project_threshold_config: ['duration', 300],
+          },
+          {
+            key_transaction: 0,
+            transaction: '/apple/checkout',
+            project: project2.slug,
+            user: 'uhoh@example.com',
+            tpm: 30,
+            p50: 100,
+            p95: 500,
+            failure_rate: 0.1,
+            apdex: 0.6,
+            count_unique_user: 1000,
+            count_miserable_user: 122,
+            user_misery: 0.114,
+            project_threshold_config: ['duration', 300],
+          },
+        ],
+      },
+    });
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: `/organizations/org-slug/key-transactions-list/`,
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: `/organizations/org-slug/legacy-key-transactions-count/`,
+      body: [],
+    });
+  });
+
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+    ProjectsStore.reset();
+  });
+
+  it('renders correct cell actions without feature', async function () {
+    const data = initializeData(projects, {query: 'event.type:transaction'});
+
+    const wrapper = mountWithTheme(
+      <Table
+        eventView={eventView}
+        organization={data.organization}
+        location={data.router.location}
+        setError={jest.fn()}
+        summaryConditions=""
+        projects={projects}
+      />
+    );
+
+    await tick();
+    wrapper.update();
+    const firstRow = wrapper.find('GridBody').find('GridRow').at(0);
+    const userMiseryCell = firstRow.find('GridBodyCell').at(9);
+    const cellAction = userMiseryCell.find('CellAction');
+
+    expect(cellAction.prop('allowActions')).toEqual([
+      'add',
+      'exclude',
+      'show_greater_than',
+      'show_less_than',
+    ]);
+
+    const menu = openContextMenu(wrapper, 8); // User Misery Cell Action
+    expect(menu.find('MenuButtons').find('ActionItem')).toHaveLength(2);
+  });
+
+  it('renders correct cell actions with feature', async function () {
+    const data = initializeData(projects, {query: 'event.type:transaction'}, [
+      'performance-view',
+      'project-transaction-threshold-override',
+    ]);
+
+    const wrapper = mountWithTheme(
+      <Table
+        eventView={eventView}
+        organization={data.organization}
+        location={data.router.location}
+        setError={jest.fn()}
+        summaryConditions=""
+        projects={projects}
+      />
+    );
+
+    await tick();
+    wrapper.update();
+    const firstRow = wrapper.find('GridBody').find('GridRow').at(0);
+    const userMiseryCell = firstRow.find('GridBodyCell').at(9);
+    const cellAction = userMiseryCell.find('CellAction');
+
+    expect(cellAction.prop('allowActions')).toEqual([
+      'add',
+      'exclude',
+      'show_greater_than',
+      'show_less_than',
+      'edit_threshold',
+    ]);
+
+    const menu = openContextMenu(wrapper, 8); // User Misery Cell Action
+    expect(menu.find('MenuButtons').find('ActionItem')).toHaveLength(3);
+    expect(menu.find('MenuButtons').find('ActionItem').at(2).text()).toEqual(
+      'Edit threshold (300ms)'
+    );
+  });
+});