Browse Source

feat(performance): Add team key transactions to performance landing a… (#26342)

This swaps the existing key transaction on the performance landing and vital
details pages for team based key transactions.
Tony Xiao 3 years ago
parent
commit
bda91a3eea

+ 1 - 0
static/app/actionCreators/events.tsx

@@ -94,6 +94,7 @@ export const doEventsRequest = (
 
 export type EventQuery = {
   field: string[];
+  team?: string | string[];
   project?: string | string[];
   sort?: string | string[];
   query: string;

+ 2 - 0
static/app/types/index.tsx

@@ -1582,6 +1582,8 @@ export type NewQuery = {
   // Graph
   yAxis?: string;
   display?: string;
+
+  teams?: Readonly<('myteams' | number)[]>;
 };
 
 export type SavedQuery = NewQuery & {

+ 38 - 0
static/app/utils/discover/eventView.tsx

@@ -211,6 +211,21 @@ const decodeQuery = (location: Location): string => {
   return decodeScalar(queryParameter, '').trim();
 };
 
+const decodeTeam = (value: string): 'myteams' | number => {
+  if (value === 'myteams') {
+    return value;
+  }
+  return parseInt(value, 10);
+};
+
+const decodeTeams = (location: Location): ('myteams' | number)[] => {
+  if (!location.query || !location.query.team) {
+    return [];
+  }
+  const value = location.query.team;
+  return Array.isArray(value) ? value.map(decodeTeam) : [decodeTeam(value)];
+};
+
 const decodeProjects = (location: Location): number[] => {
   if (!location.query || !location.query.project) {
     return [];
@@ -237,6 +252,7 @@ class EventView {
   fields: Readonly<Field[]>;
   sorts: Readonly<Sort[]>;
   query: string;
+  team: Readonly<('myteams' | number)[]>;
   project: Readonly<number[]>;
   start: string | undefined;
   end: string | undefined;
@@ -255,6 +271,7 @@ class EventView {
     fields: Readonly<Field[]>;
     sorts: Readonly<Sort[]>;
     query: string;
+    team: Readonly<('myteams' | number)[]>;
     project: Readonly<number[]>;
     start: string | undefined;
     end: string | undefined;
@@ -269,6 +286,7 @@ class EventView {
   }) {
     const fields: Field[] = Array.isArray(props.fields) ? props.fields : [];
     let sorts: Sort[] = Array.isArray(props.sorts) ? props.sorts : [];
+    const team = Array.isArray(props.team) ? props.team : [];
     const project = Array.isArray(props.project) ? props.project : [];
     const environment = Array.isArray(props.environment) ? props.environment : [];
 
@@ -287,6 +305,7 @@ class EventView {
     this.fields = fields;
     this.sorts = sorts;
     this.query = typeof props.query === 'string' ? props.query : '';
+    this.team = team;
     this.project = project;
     this.start = props.start;
     this.end = props.end;
@@ -309,6 +328,7 @@ class EventView {
       fields: decodeFields(location),
       sorts: decodeSorts(location),
       query: decodeQuery(location),
+      team: decodeTeams(location),
       project: decodeProjects(location),
       start: decodeScalar(start),
       end: decodeScalar(end),
@@ -374,6 +394,7 @@ class EventView {
       name: saved.name,
       fields,
       query: queryStringFromSavedQuery(saved),
+      team: saved.teams ?? [],
       project: saved.projects,
       start: decodeScalar(start),
       end: decodeScalar(end),
@@ -400,6 +421,7 @@ class EventView {
     let fields = decodeFields(location);
     const {start, end, statsPeriod} = getParams(location.query);
     const id = decodeScalar(location.query.id);
+    const teams = decodeTeams(location);
     const projects = decodeProjects(location);
     const sorts = decodeSorts(location);
     const environments = collectQueryStringByKey(location.query, 'environment');
@@ -423,6 +445,9 @@ class EventView {
         createdBy: saved.createdBy,
         expired: saved.expired,
         additionalConditions: new QueryResults([]),
+        // Always read team from location since they can be set by other parts
+        // of the UI
+        team: teams,
         // Always read project and environment from location since they can
         // be set by the GlobalSelectionHeaders.
         project: projects,
@@ -639,6 +664,7 @@ class EventView {
       fields: this.fields,
       sorts: this.sorts,
       query: this.query,
+      team: this.team,
       project: this.project,
       start: this.start,
       end: this.end,
@@ -895,6 +921,12 @@ class EventView {
     return newEventView;
   }
 
+  withTeams(teams: ('myteams' | number)[]): EventView {
+    const newEventView = this.clone();
+    newEventView.team = teams;
+    return newEventView;
+  }
+
   getSorts(): TableColumnSort<React.ReactText>[] {
     return this.sorts.map(
       sort =>
@@ -993,6 +1025,7 @@ class EventView {
         ? encodeSorts(this.sorts)
         : encodeSort(this.sorts[0]);
     const fields = this.getFields();
+    const team = this.team.map(proj => String(proj));
     const project = this.project.map(proj => String(proj));
     const environment = this.environment as string[];
 
@@ -1001,6 +1034,7 @@ class EventView {
       omit(picked, DATETIME_QUERY_STRING_KEYS),
       normalizedTimeWindowParams,
       {
+        team,
         project,
         environment,
         field: [...new Set(fields)],
@@ -1010,6 +1044,10 @@ class EventView {
       }
     ) as EventQuery & LocationQuery;
 
+    if (eventQuery.team && !eventQuery.team.length) {
+      delete eventQuery.team;
+    }
+
     if (!eventQuery.sort) {
       delete eventQuery.sort;
     }

+ 16 - 1
static/app/utils/discover/fieldRenderers.tsx

@@ -49,6 +49,7 @@ import {
   UserIcon,
   VersionContainer,
 } from './styles';
+import TeamKeyTransactionField from './teamKeyTransactionField';
 
 /**
  * Types, functions and definitions for rendering fields in discover results.
@@ -195,6 +196,7 @@ type SpecialFields = {
   issue: SpecialField;
   release: SpecialField;
   key_transaction: SpecialField;
+  team_key_transaction: SpecialField;
   'trend_percentage()': SpecialField;
   'timestamp.to_hour': SpecialField;
   'timestamp.to_day': SpecialField;
@@ -371,7 +373,7 @@ const SPECIAL_FIELDS: SpecialFields = {
     },
   },
   key_transaction: {
-    sortField: 'key_transaction',
+    sortField: null,
     renderFunc: (data, {organization}) => (
       <Container>
         <KeyTransactionField
@@ -383,6 +385,19 @@ const SPECIAL_FIELDS: SpecialFields = {
       </Container>
     ),
   },
+  team_key_transaction: {
+    sortField: null,
+    renderFunc: (data, {organization}) => (
+      <Container>
+        <TeamKeyTransactionField
+          isKeyTransaction={(data.team_key_transaction ?? 0) !== 0}
+          organization={organization}
+          projectSlug={data.project}
+          transactionName={data.transaction}
+        />
+      </Container>
+    ),
+  },
   'trend_percentage()': {
     sortField: 'trend_percentage()',
     renderFunc: data => (

+ 101 - 0
static/app/utils/discover/teamKeyTransactionField.tsx

@@ -0,0 +1,101 @@
+import {Component} from 'react';
+
+import Button from 'app/components/button';
+import TeamKeyTransaction, {
+  TitleProps,
+} from 'app/components/performance/teamKeyTransaction';
+import * as TeamKeyTransactionManager from 'app/components/performance/teamKeyTransactionsManager';
+import {IconStar} from 'app/icons';
+import {Organization, Project, Team} from 'app/types';
+import {defined} from 'app/utils';
+import withProjects from 'app/utils/withProjects';
+import withTeams from 'app/utils/withTeams';
+
+class TitleStar extends Component<TitleProps> {
+  render() {
+    const {keyedTeamsCount, ...props} = this.props;
+    const star = (
+      <IconStar
+        color={keyedTeamsCount ? 'yellow300' : 'gray200'}
+        isSolid={keyedTeamsCount > 0}
+        data-test-id="team-key-transaction-column"
+      />
+    );
+    return <Button {...props} icon={star} borderless size="zero" />;
+  }
+}
+
+type BaseProps = {
+  teams: Team[];
+  organization: Organization;
+  isKeyTransaction: boolean;
+};
+
+type Props = BaseProps &
+  TeamKeyTransactionManager.TeamKeyTransactionManagerChildrenProps & {
+    project: number;
+    transactionName: string;
+  };
+
+function TeamKeyTransactionField({
+  isKeyTransaction,
+  counts,
+  getKeyedTeams,
+  project,
+  transactionName,
+  ...props
+}: Props) {
+  const keyedTeams = getKeyedTeams(String(project), transactionName);
+
+  return (
+    <TeamKeyTransaction
+      counts={counts}
+      keyedTeams={keyedTeams}
+      title={TitleStar}
+      project={project}
+      transactionName={transactionName}
+      initialValue={Number(isKeyTransaction)}
+      {...props}
+    />
+  );
+}
+
+type WrapperProps = BaseProps & {
+  projects: Project[];
+  projectSlug: string | undefined;
+  transactionName: string | undefined;
+};
+
+function TeamKeyTransactionFieldWrapper({
+  isKeyTransaction,
+  projects,
+  projectSlug,
+  transactionName,
+  ...props
+}: WrapperProps) {
+  const project = projects.find(proj => proj.slug === projectSlug);
+  const projectId = project ? parseInt(project.id, 10) : null;
+
+  // All these fields need to be defined in order to toggle a team key
+  // transaction. Since they are not defined, just render a plain star
+  // with no interactions.
+  if (!defined(projectId) || !defined(transactionName)) {
+    return <TitleStar keyedTeamsCount={Number(isKeyTransaction)} />;
+  }
+
+  return (
+    <TeamKeyTransactionManager.Consumer>
+      {results => (
+        <TeamKeyTransactionField
+          isKeyTransaction={isKeyTransaction}
+          project={projectId}
+          transactionName={transactionName}
+          {...props}
+          {...results}
+        />
+      )}
+    </TeamKeyTransactionManager.Consumer>
+  );
+}
+
+export default withTeams(withProjects(TeamKeyTransactionFieldWrapper));

+ 36 - 12
static/app/views/performance/data.tsx

@@ -289,7 +289,9 @@ function generateGenericPerformanceEventView(
   const {query} = location;
 
   const fields = [
-    'key_transaction',
+    organization.features.includes('team-key-transactions')
+      ? 'team_key_transaction'
+      : 'key_transaction',
     'transaction',
     'project',
     'tpm()',
@@ -359,7 +361,9 @@ function generateBackendPerformanceEventView(
   const {query} = location;
 
   const fields = [
-    'key_transaction',
+    organization.features.includes('team-key-transactions')
+      ? 'team_key_transaction'
+      : 'key_transaction',
     'transaction',
     'project',
     'transaction.op',
@@ -431,7 +435,9 @@ function generateFrontendPageloadPerformanceEventView(
   const {query} = location;
 
   const fields = [
-    'key_transaction',
+    organization.features.includes('team-key-transactions')
+      ? 'team_key_transaction'
+      : 'key_transaction',
     'transaction',
     'project',
     'tpm()',
@@ -498,7 +504,9 @@ function generateFrontendOtherPerformanceEventView(
   const {query} = location;
 
   const fields = [
-    'key_transaction',
+    organization.features.includes('team-key-transactions')
+      ? 'team_key_transaction'
+      : 'key_transaction',
     'transaction',
     'project',
     'transaction.op',
@@ -564,7 +572,7 @@ export function generatePerformanceEventView(
   projects,
   isTrends = false
 ) {
-  const eventView = generateGenericPerformanceEventView(organization, location);
+  let eventView = generateGenericPerformanceEventView(organization, location);
   if (isTrends) {
     return eventView;
   }
@@ -572,18 +580,27 @@ export function generatePerformanceEventView(
   const display = getCurrentLandingDisplay(location, projects, eventView);
   switch (display?.field) {
     case LandingDisplayField.FRONTEND_PAGELOAD:
-      return generateFrontendPageloadPerformanceEventView(organization, location);
+      eventView = generateFrontendPageloadPerformanceEventView(organization, location);
+      break;
     case LandingDisplayField.FRONTEND_OTHER:
-      return generateFrontendOtherPerformanceEventView(organization, location);
+      eventView = generateFrontendOtherPerformanceEventView(organization, location);
+      break;
     case LandingDisplayField.BACKEND:
-      return generateBackendPerformanceEventView(organization, location);
+      eventView = generateBackendPerformanceEventView(organization, location);
+      break;
     default:
-      return eventView;
+      break;
+  }
+
+  if (organization.features.includes('team-key-transactions')) {
+    return eventView.withTeams(['myteams']);
+  } else {
+    return eventView;
   }
 }
 
 export function generatePerformanceVitalDetailView(
-  _organization: LightWeightOrganization,
+  organization: LightWeightOrganization,
   location: Location
 ): EventView {
   const {query} = location;
@@ -597,7 +614,9 @@ export function generatePerformanceVitalDetailView(
     query: 'event.type:transaction',
     projects: [],
     fields: [
-      'key_transaction',
+      organization.features.includes('team-key-transactions')
+        ? 'team_key_transaction'
+        : 'key_transaction',
       'transaction',
       'project',
       'count_unique(user)',
@@ -631,5 +650,10 @@ export function generatePerformanceVitalDetailView(
   eventView.additionalConditions
     .addTagValues('event.type', ['transaction'])
     .addTagValues('has', [vitalName]);
-  return eventView;
+
+  if (organization.features.includes('team-key-transactions')) {
+    return eventView.withTeams(['myteams']);
+  } else {
+    return eventView;
+  }
 }

+ 24 - 4
static/app/views/performance/landing/content.tsx

@@ -3,17 +3,20 @@ import {browserHistory, withRouter, WithRouterProps} from 'react-router';
 import styled from '@emotion/styled';
 import {Location} from 'history';
 
+import Feature from 'app/components/acl/feature';
 import DropdownControl, {DropdownItem} from 'app/components/dropdownControl';
 import SearchBar from 'app/components/events/searchBar';
+import * as TeamKeyTransactionManager from 'app/components/performance/teamKeyTransactionsManager';
 import {MAX_QUERY_LENGTH} from 'app/constants';
 import {t} from 'app/locale';
 import space from 'app/styles/space';
-import {Organization, Project} from 'app/types';
+import {Organization, Project, Team} from 'app/types';
 import {trackAnalyticsEvent} from 'app/utils/analytics';
 import EventView from 'app/utils/discover/eventView';
 import {generateAggregateFields} from 'app/utils/discover/fields';
 import {decodeScalar} from 'app/utils/queryString';
 import {stringifyQueryObject, tokenizeSearch} from 'app/utils/tokenizeSearch';
+import withTeams from 'app/utils/withTeams';
 
 import Charts from '../charts/index';
 import {
@@ -46,6 +49,7 @@ type Props = {
   eventView: EventView;
   location: Location;
   projects: Project[];
+  teams: Team[];
   setError: (msg: string | undefined) => void;
   handleSearch: (searchQuery: string) => void;
 } & WithRouterProps;
@@ -213,10 +217,11 @@ class LandingContent extends Component<Props, State> {
   };
 
   render() {
-    const {organization, location, eventView, projects, handleSearch} = this.props;
+    const {organization, location, eventView, projects, teams, handleSearch} = this.props;
 
     const currentLandingDisplay = getCurrentLandingDisplay(location, projects, eventView);
     const filterString = getTransactionSearchQuery(location, eventView.query);
+    const userTeams = teams.filter(({isMember}) => isMember);
 
     return (
       <Fragment>
@@ -250,7 +255,22 @@ class LandingContent extends Component<Props, State> {
             ))}
           </DropdownControl>
         </SearchContainer>
-        {this.renderSelectedDisplay(currentLandingDisplay.field)}
+        <Feature organization={organization} features={['team-key-transactions']}>
+          {({hasFeature}) =>
+            hasFeature ? (
+              <TeamKeyTransactionManager.Provider
+                organization={organization}
+                teams={userTeams}
+                selectedTeams={['myteams']}
+                selectedProjects={eventView.project.map(String)}
+              >
+                {this.renderSelectedDisplay(currentLandingDisplay.field)}
+              </TeamKeyTransactionManager.Provider>
+            ) : (
+              this.renderSelectedDisplay(currentLandingDisplay.field)
+            )
+          }
+        </Feature>
       </Fragment>
     );
   }
@@ -263,4 +283,4 @@ const SearchContainer = styled('div')`
   margin-bottom: ${space(2)};
 `;
 
-export default withRouter(LandingContent);
+export default withRouter(withTeams(LandingContent));

+ 43 - 20
static/app/views/performance/table.tsx

@@ -144,6 +144,11 @@ class Table extends React.Component<Props, State> {
       return rendered;
     }
 
+    if (field.startsWith('team_key_transaction')) {
+      // don't display per cell actions for team_key_transaction
+      return rendered;
+    }
+
     const fieldName = getAggregateAlias(field);
     const value = dataRow[fieldName];
     if (tableMeta[fieldName] === 'integer' && defined(value) && value > 999) {
@@ -219,8 +224,7 @@ class Table extends React.Component<Props, State> {
       };
     }
     const currentSort = eventView.sortForField(field, tableMeta);
-    const canSort =
-      isFieldSortable(field, tableMeta) && field.field !== 'key_transaction';
+    const canSort = isFieldSortable(field, tableMeta);
 
     const currentSortKind = currentSort ? currentSort.kind : undefined;
     const currentSortField = currentSort ? currentSort.field : undefined;
@@ -256,24 +260,40 @@ class Table extends React.Component<Props, State> {
     const keyTransactionColumn = eventView
       .getColumns()
       .find((col: TableColumn<React.ReactText>) => col.name === 'key_transaction');
+    const teamKeyTransactionColumn = eventView
+      .getColumns()
+      .find((col: TableColumn<React.ReactText>) => col.name === 'team_key_transaction');
     return (isHeader: boolean, dataRow?: any) => {
-      if (!keyTransactionColumn) {
-        return [];
-      }
-
-      if (isHeader) {
-        const star = (
-          <IconStar
-            key="keyTransaction"
-            color="yellow300"
-            isSolid
-            data-test-id="key-transaction-header"
-          />
-        );
-        return [this.renderHeadCell(tableData?.meta, keyTransactionColumn, star)];
-      } else {
-        return [this.renderBodyCell(tableData, keyTransactionColumn, dataRow)];
+      if (keyTransactionColumn) {
+        if (isHeader) {
+          const star = (
+            <IconStar
+              key="keyTransaction"
+              color="yellow300"
+              isSolid
+              data-test-id="key-transaction-header"
+            />
+          );
+          return [this.renderHeadCell(tableData?.meta, keyTransactionColumn, star)];
+        } else {
+          return [this.renderBodyCell(tableData, keyTransactionColumn, dataRow)];
+        }
+      } else if (teamKeyTransactionColumn) {
+        if (isHeader) {
+          const star = (
+            <IconStar
+              key="keyTransaction"
+              color="yellow300"
+              isSolid
+              data-test-id="team-key-transaction-header"
+            />
+          );
+          return [this.renderHeadCell(tableData?.meta, teamKeyTransactionColumn, star)];
+        } else {
+          return [this.renderBodyCell(tableData, teamKeyTransactionColumn, dataRow)];
+        }
       }
+      return [];
     };
   };
 
@@ -295,11 +315,13 @@ class Table extends React.Component<Props, State> {
   };
 
   getSortedEventView() {
-    const {eventView} = this.props;
+    const {eventView, organization} = this.props;
 
     return eventView.withSorts([
       {
-        field: 'key_transaction',
+        field: organization.features.includes('team-key-transactions')
+          ? 'team_key_transaction'
+          : 'key_transaction',
         kind: 'desc',
       },
       ...eventView.sorts,
@@ -317,6 +339,7 @@ class Table extends React.Component<Props, State> {
       .filter(
         (col: TableColumn<React.ReactText>) =>
           col.name !== 'key_transaction' &&
+          col.name !== 'team_key_transaction' &&
           !col.name.startsWith('count_miserable') &&
           col.name !== 'project_threshold_config'
       )

+ 1 - 0
static/app/views/performance/transactionSummary/relatedIssues.tsx

@@ -57,6 +57,7 @@ class RelatedIssues extends Component<Props> {
 
     // Filter out key_transaction from being passed to issues as it will cause an error.
     currentFilter.removeTag('key_transaction');
+    currentFilter.removeTag('team_key_transaction');
 
     return {
       path: `/organizations/${organization.slug}/issues/`,

+ 48 - 19
static/app/views/performance/vitalDetail/table.tsx

@@ -207,6 +207,10 @@ class Table extends React.Component<Props, State> {
       return rendered;
     }
 
+    if (field.startsWith('team_key_transaction')) {
+      return rendered;
+    }
+
     return (
       <CellAction
         column={column}
@@ -272,24 +276,44 @@ class Table extends React.Component<Props, State> {
     const keyTransactionColumn = eventView
       .getColumns()
       .find((col: TableColumn<React.ReactText>) => col.name === 'key_transaction');
+    const teamKeyTransactionColumn = eventView
+      .getColumns()
+      .find((col: TableColumn<React.ReactText>) => col.name === 'team_key_transaction');
     return (isHeader: boolean, dataRow?: any) => {
-      if (!keyTransactionColumn) {
-        return [];
-      }
-
-      if (isHeader) {
-        const star = (
-          <IconStar
-            key="keyTransaction"
-            color="yellow300"
-            isSolid
-            data-test-id="key-transaction-header"
-          />
-        );
-        return [this.renderHeadCell(tableData?.meta, keyTransactionColumn, star)];
-      } else {
-        return [this.renderBodyCell(tableData, keyTransactionColumn, dataRow, vitalName)];
+      if (keyTransactionColumn) {
+        if (isHeader) {
+          const star = (
+            <IconStar
+              key="keyTransaction"
+              color="yellow300"
+              isSolid
+              data-test-id="key-transaction-header"
+            />
+          );
+          return [this.renderHeadCell(tableData?.meta, keyTransactionColumn, star)];
+        } else {
+          return [
+            this.renderBodyCell(tableData, keyTransactionColumn, dataRow, vitalName),
+          ];
+        }
+      } else if (teamKeyTransactionColumn) {
+        if (isHeader) {
+          const star = (
+            <IconStar
+              key="keyTransaction"
+              color="yellow300"
+              isSolid
+              data-test-id="key-transaction-header"
+            />
+          );
+          return [this.renderHeadCell(tableData?.meta, teamKeyTransactionColumn, star)];
+        } else {
+          return [
+            this.renderBodyCell(tableData, teamKeyTransactionColumn, dataRow, vitalName),
+          ];
+        }
       }
+      return [];
     };
   };
 
@@ -311,7 +335,7 @@ class Table extends React.Component<Props, State> {
   };
 
   getSortedEventView(vitalName: WebVital) {
-    const {eventView} = this.props;
+    const {eventView, organization} = this.props;
 
     const aggregateFieldPoor = getAggregateAlias(
       getVitalDetailTablePoorStatusFunction(vitalName)
@@ -328,7 +352,9 @@ class Table extends React.Component<Props, State> {
       ? []
       : [
           {
-            field: 'key_transaction',
+            field: organization.features.includes('team-key-transactions')
+              ? 'team_key_transaction'
+              : 'key_transaction',
             kind: 'desc',
           },
           {
@@ -354,7 +380,10 @@ class Table extends React.Component<Props, State> {
       .getColumns()
       // remove key_transactions from the column order as we'll be rendering it
       // via a prepended column
-      .filter((col: TableColumn<React.ReactText>) => col.name !== 'key_transaction')
+      .filter(
+        (col: TableColumn<React.ReactText>) =>
+          col.name !== 'key_transaction' && col.name !== 'team_key_transaction'
+      )
       .slice(0, -1)
       .map((col: TableColumn<React.ReactText>, i: number) => {
         if (typeof widths[i] === 'number') {

Some files were not shown because too many files changed in this diff