Browse Source

feat(performance): Add team key transaction button to transaction summary (#26142)

This replaces the key transaction button on the transaction summary page with a
team based key transaction when the `team-key-transactions` flag is enabled.
Tony Xiao 3 years ago
parent
commit
e88f8ba72b

+ 16 - 4
static/app/actionCreators/performance.tsx

@@ -1,4 +1,8 @@
-import {addErrorMessage} from 'app/actionCreators/indicator';
+import {
+  addErrorMessage,
+  addLoadingMessage,
+  clearIndicators,
+} from 'app/actionCreators/indicator';
 import {Client} from 'app/api';
 import {t} from 'app/locale';
 
@@ -6,9 +10,12 @@ export function toggleKeyTransaction(
   api: Client,
   isKeyTransaction: boolean,
   orgId: string,
-  projects: number[],
-  transactionName: string
+  projects: Readonly<number[]>,
+  transactionName: string,
+  teamIds?: string[] // TODO(txiao): make this required
 ): Promise<undefined> {
+  addLoadingMessage(t('Saving changes\u2026'));
+
   const promise: Promise<undefined> = api.requestPromise(
     `/organizations/${orgId}/key-transactions/`,
     {
@@ -16,10 +23,15 @@ export function toggleKeyTransaction(
       query: {
         project: projects.map(id => String(id)),
       },
-      data: {transaction: transactionName},
+      data: {
+        transaction: transactionName,
+        team: teamIds,
+      },
     }
   );
 
+  promise.then(clearIndicators);
+
   promise.catch(response => {
     const non_field_errors = response?.responseJSON?.non_field_errors;
 

+ 235 - 0
static/app/components/performance/teamKeyTransaction.tsx

@@ -0,0 +1,235 @@
+import {Component, ReactElement} from 'react';
+import styled from '@emotion/styled';
+
+import {toggleKeyTransaction} from 'app/actionCreators/performance';
+import {Client} from 'app/api';
+import CheckboxFancy from 'app/components/checkboxFancy/checkboxFancy';
+import DropdownLink from 'app/components/dropdownLink';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {Organization, Team} from 'app/types';
+import withApi from 'app/utils/withApi';
+
+export type TitleProps = {
+  keyedTeamsCount: number;
+  disabled?: boolean;
+};
+
+type Props = {
+  api: Client;
+  project: number;
+  organization: Organization;
+  teams: Team[];
+  transactionName: string;
+  title: (props: TitleProps) => ReactElement;
+};
+
+type State = {
+  isLoading: boolean;
+  keyFetchID: symbol | undefined;
+  error: null | string;
+  keyedTeams: Set<string>;
+};
+
+type SelectionAction = {action: 'key' | 'unkey'};
+type MyTeamSelection = SelectionAction & {type: 'my teams'};
+type TeamIdSelection = SelectionAction & {type: 'id'; teamId: string};
+type TeamSelection = MyTeamSelection | TeamIdSelection;
+
+function isMyTeamSelection(selection: TeamSelection): selection is MyTeamSelection {
+  return selection.type === 'my teams';
+}
+
+class TeamKeyTransaction extends Component<Props, State> {
+  state: State = {
+    isLoading: true,
+    keyFetchID: undefined,
+    error: null,
+    keyedTeams: new Set(),
+  };
+
+  componentDidMount() {
+    this.fetchData();
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    const orgSlugChanged = prevProps.organization.slug !== this.props.organization.slug;
+    const projectsChanged = prevProps.project !== this.props.project;
+    const transactionChanged = prevProps.transactionName !== this.props.transactionName;
+    if (orgSlugChanged || projectsChanged || transactionChanged) {
+      this.fetchData();
+    }
+  }
+
+  async fetchData() {
+    const {api, organization, project, transactionName} = this.props;
+
+    const url = `/organizations/${organization.slug}/key-transactions/`;
+    const keyFetchID = Symbol('keyFetchID');
+
+    this.setState({isLoading: true, keyFetchID});
+
+    try {
+      const [data] = await api.requestPromise(url, {
+        method: 'GET',
+        includeAllArgs: true,
+        query: {
+          project: String(project),
+          transaction: transactionName,
+        },
+      });
+      this.setState({
+        isLoading: false,
+        keyFetchID: undefined,
+        error: null,
+        keyedTeams: new Set(data.map(({team}) => team)),
+      });
+    } catch (err) {
+      this.setState({
+        isLoading: false,
+        keyFetchID: undefined,
+        error: err.responseJSON?.detail ?? null,
+      });
+    }
+  }
+
+  handleToggleKeyTransaction = async (selection: TeamSelection) => {
+    // TODO: handle the max 100 limit
+    const {api, organization, project, teams, transactionName} = this.props;
+    const markAsKeyTransaction = selection.action === 'key';
+
+    let teamIds;
+    let keyedTeams;
+    if (isMyTeamSelection(selection)) {
+      teamIds = teams.map(({id}) => id);
+      if (markAsKeyTransaction) {
+        keyedTeams = new Set(teamIds);
+      } else {
+        keyedTeams = new Set();
+      }
+    } else {
+      teamIds = [selection.teamId];
+      keyedTeams = new Set(this.state.keyedTeams);
+      if (markAsKeyTransaction) {
+        keyedTeams.add(selection.teamId);
+      } else {
+        keyedTeams.delete(selection.teamId);
+      }
+    }
+
+    try {
+      await toggleKeyTransaction(
+        api,
+        !markAsKeyTransaction,
+        organization.slug,
+        [project],
+        transactionName,
+        teamIds
+      );
+      this.setState({
+        isLoading: false,
+        keyFetchID: undefined,
+        error: null,
+        keyedTeams,
+      });
+    } catch (err) {
+      this.setState({
+        isLoading: false,
+        keyFetchID: undefined,
+        error: err.responseJSON?.detail ?? null,
+      });
+    }
+  };
+
+  render() {
+    const {teams, title: Title} = this.props;
+    const {keyedTeams, isLoading} = this.state;
+
+    if (isLoading) {
+      return <Title disabled keyedTeamsCount={keyedTeams.size} />;
+    }
+
+    return (
+      <TeamKeyTransactionSelector
+        title={<Title keyedTeamsCount={keyedTeams.size} />}
+        handleToggleKeyTransaction={this.handleToggleKeyTransaction}
+        teams={teams}
+        keyedTeams={keyedTeams}
+      />
+    );
+  }
+}
+
+type SelectorProps = {
+  title: React.ReactNode;
+  handleToggleKeyTransaction: (selection: TeamSelection) => void;
+  teams: Team[];
+  keyedTeams: Set<string>;
+};
+
+function TeamKeyTransactionSelector({
+  title,
+  handleToggleKeyTransaction,
+  teams,
+  keyedTeams,
+}: SelectorProps) {
+  const toggleTeam = (team: TeamSelection) => e => {
+    e.stopPropagation();
+    handleToggleKeyTransaction(team);
+  };
+
+  return (
+    <DropdownLink caret={false} title={title} anchorMiddle>
+      <DropdownMenuHeader
+        first
+        onClick={toggleTeam({
+          type: 'my teams',
+          action: teams.length === keyedTeams.size ? 'unkey' : 'key',
+        })}
+      >
+        {t('My Teams')}
+        <StyledCheckbox
+          isChecked={teams.length === keyedTeams.size}
+          isIndeterminate={teams.length > keyedTeams.size && keyedTeams.size > 0}
+        />
+      </DropdownMenuHeader>
+      {teams.map(team => (
+        <DropdownMenuItem
+          key={team.slug}
+          onClick={toggleTeam({
+            type: 'id',
+            action: keyedTeams.has(team.id) ? 'unkey' : 'key',
+            teamId: team.id,
+          })}
+        >
+          {team.name}
+          <StyledCheckbox isChecked={keyedTeams.has(team.id)} />
+        </DropdownMenuItem>
+      ))}
+    </DropdownLink>
+  );
+}
+
+const DropdownMenuItemBase = styled('li')`
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  padding: ${space(1)} ${space(1.5)};
+`;
+
+const DropdownMenuHeader = styled(DropdownMenuItemBase)<{first?: boolean}>`
+  background: ${p => p.theme.backgroundSecondary};
+  ${p => p.first && 'border-radius: 2px'};
+`;
+
+const DropdownMenuItem = styled(DropdownMenuItemBase)`
+  border-top: 1px solid ${p => p.theme.border};
+`;
+
+const StyledCheckbox = styled(CheckboxFancy)`
+  min-width: ${space(2)};
+  margin-left: ${space(1)};
+`;
+
+export default withApi(TeamKeyTransaction);

+ 18 - 5
static/app/views/performance/transactionSummary/header.tsx

@@ -20,6 +20,7 @@ import Breadcrumb from 'app/views/performance/breadcrumb';
 import {tagsRouteWithQuery} from './transactionTags/utils';
 import {vitalsRouteWithQuery} from './transactionVitals/utils';
 import KeyTransactionButton from './keyTransactionButton';
+import TeamKeyTransactionButton from './teamKeyTransactionButton';
 import {transactionSummaryRouteWithQuery} from './utils';
 
 export enum Tab {
@@ -97,11 +98,23 @@ class TransactionHeader extends React.Component<Props> {
     const {eventView, organization, transactionName} = this.props;
 
     return (
-      <KeyTransactionButton
-        transactionName={transactionName}
-        eventView={eventView}
-        organization={organization}
-      />
+      <Feature organization={organization} features={['team-key-transactions']}>
+        {({hasFeature}) =>
+          hasFeature ? (
+            <TeamKeyTransactionButton
+              transactionName={transactionName}
+              eventView={eventView}
+              organization={organization}
+            />
+          ) : (
+            <KeyTransactionButton
+              transactionName={transactionName}
+              eventView={eventView}
+              organization={organization}
+            />
+          )
+        }
+      </Feature>
     );
   }
 

+ 53 - 0
static/app/views/performance/transactionSummary/teamKeyTransactionButton.tsx

@@ -0,0 +1,53 @@
+import styled from '@emotion/styled';
+
+import Button from 'app/components/button';
+import TeamKeyTransaction, {
+  TitleProps,
+} from 'app/components/performance/teamKeyTransaction';
+import {IconStar} from 'app/icons';
+import {t, tn} from 'app/locale';
+import {Organization, Team} from 'app/types';
+import EventView from 'app/utils/discover/eventView';
+import withTeams from 'app/utils/withTeams';
+
+type Props = {
+  eventView: EventView;
+  organization: Organization;
+  teams: Team[];
+  transactionName: string;
+};
+
+function TeamKeyTransactionButton({eventView, teams, ...props}: Props) {
+  if (eventView.project.length !== 1) {
+    return <TitleButton disabled keyedTeamsCount={0} />;
+  }
+
+  const userTeams = teams.filter(({isMember}) => isMember);
+  return (
+    <TeamKeyTransaction
+      teams={userTeams}
+      project={eventView.project[0]}
+      title={TitleButton}
+      {...props}
+    />
+  );
+}
+
+function TitleButton({disabled, keyedTeamsCount}: TitleProps) {
+  return (
+    <StyledButton
+      disabled={disabled}
+      icon={keyedTeamsCount ? <IconStar color="yellow300" isSolid /> : <IconStar />}
+    >
+      {keyedTeamsCount
+        ? tn('Starred for Team', 'Starred for Teams', keyedTeamsCount)
+        : t('Star for Team')}
+    </StyledButton>
+  );
+}
+
+const StyledButton = styled(Button)`
+  width: 180px;
+`;
+
+export default withTeams(TeamKeyTransactionButton);

+ 336 - 0
tests/js/spec/components/performance/teamKeyTransaction.spec.jsx

@@ -0,0 +1,336 @@
+import {mountWithTheme} from 'sentry-test/enzyme';
+
+import TeamKeyTransaction from 'app/components/performance/teamKeyTransaction';
+
+function TestTitle({disabled, keyedTeamsCount}) {
+  return <p>{disabled ? 'disabled' : `count: ${keyedTeamsCount}`}</p>;
+}
+
+describe('TeamKeyTransaction', function () {
+  const organization = TestStubs.Organization({features: ['performance-view']});
+  const project = TestStubs.Project();
+  const teams = [
+    TestStubs.Team({id: '1', slug: 'team1', name: 'Team 1'}),
+    TestStubs.Team({id: '2', slug: 'team2', name: 'Team 2'}),
+  ];
+
+  beforeEach(function () {
+    jest.clearAllMocks();
+    MockApiClient.clearMockResponses();
+  });
+
+  it('renders with all teams checked', async function () {
+    const getTeamKeyTransactionsMock = MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/key-transactions/',
+      body: teams.map(({id}) => ({team: id})),
+    });
+
+    const wrapper = mountWithTheme(
+      <TeamKeyTransaction
+        project={project.id}
+        organization={organization}
+        teams={teams}
+        transactionName="transaction"
+        title={TestTitle}
+      />
+    );
+
+    await tick();
+    wrapper.update();
+
+    // header should show the checked state
+    expect(getTeamKeyTransactionsMock).toHaveBeenCalledTimes(1);
+    expect(wrapper.find('TestTitle').exists()).toBeTruthy();
+    const header = wrapper.find('DropdownMenuHeader');
+    expect(header.exists()).toBeTruthy();
+    expect(header.find('StyledCheckbox').props().isChecked).toBeTruthy();
+    expect(header.find('StyledCheckbox').props().isIndeterminate).toBeFalsy();
+
+    // all teams should be checked
+    const entries = wrapper.find('DropdownMenuItem');
+    expect(entries.length).toBe(2);
+    entries.forEach((entry, i) => {
+      expect(entry.text()).toEqual(teams[i].name);
+      expect(entry.find('StyledCheckbox').props().isChecked).toBeTruthy();
+    });
+  });
+
+  it('renders with some teams checked', async function () {
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/key-transactions/',
+      body: [{team: teams[0].id}],
+    });
+
+    const wrapper = mountWithTheme(
+      <TeamKeyTransaction
+        project={project.id}
+        organization={organization}
+        teams={teams}
+        transactionName="transaction"
+        title={TestTitle}
+      />
+    );
+
+    await tick();
+    wrapper.update();
+
+    // header should show the indeterminate state
+    const header = wrapper.find('DropdownMenuHeader');
+    expect(header.exists()).toBeTruthy();
+    expect(header.find('StyledCheckbox').props().isChecked).toBeFalsy();
+    expect(header.find('StyledCheckbox').props().isIndeterminate).toBeTruthy();
+
+    // only team 1 should be checked
+    const entries = wrapper.find('DropdownMenuItem');
+    expect(entries.length).toBe(2);
+    entries.forEach((entry, i) => {
+      expect(entry.text()).toEqual(teams[i].name);
+    });
+    expect(entries.at(0).find('StyledCheckbox').props().isChecked).toBeTruthy();
+    expect(entries.at(1).find('StyledCheckbox').props().isChecked).toBeFalsy();
+  });
+
+  it('renders with no teams checked', async function () {
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/key-transactions/',
+      body: [],
+    });
+
+    const wrapper = mountWithTheme(
+      <TeamKeyTransaction
+        project={project.id}
+        organization={organization}
+        teams={teams}
+        transactionName="transaction"
+        title={TestTitle}
+      />
+    );
+
+    await tick();
+    wrapper.update();
+
+    // header should show the unchecked state
+    const header = wrapper.find('DropdownMenuHeader');
+    expect(header.exists()).toBeTruthy();
+    expect(header.find('StyledCheckbox').props().isChecked).toBeFalsy();
+    expect(header.find('StyledCheckbox').props().isIndeterminate).toBeFalsy();
+
+    // all teams should be unchecked
+    const entries = wrapper.find('DropdownMenuItem');
+    expect(entries.length).toBe(2);
+    entries.forEach((entry, i) => {
+      expect(entry.text()).toEqual(teams[i].name);
+      expect(entry.find('StyledCheckbox').props().isChecked).toBeFalsy();
+    });
+  });
+
+  it('should be able to check one team', async function () {
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/key-transactions/',
+      body: [],
+    });
+
+    const postTeamKeyTransactionsMock = MockApiClient.addMockResponse(
+      {
+        method: 'POST',
+        url: '/organizations/org-slug/key-transactions/',
+        body: [],
+      },
+      {
+        predicate: (_, options) =>
+          options.method === 'POST' &&
+          options.query.project.length === 1 &&
+          options.query.project[0] === project.id &&
+          options.data.team.length === 1 &&
+          options.data.team[0] === teams[0].id &&
+          options.data.transaction === 'transaction',
+      }
+    );
+
+    const wrapper = mountWithTheme(
+      <TeamKeyTransaction
+        project={project.id}
+        organization={organization}
+        teams={teams}
+        transactionName="transaction"
+        title={TestTitle}
+      />
+    );
+
+    await tick();
+    wrapper.update();
+
+    wrapper.find('DropdownMenuItem').first().simulate('click');
+
+    await tick();
+    wrapper.update();
+
+    const entries = wrapper.find('DropdownMenuItem');
+    expect(entries.at(0).find('StyledCheckbox').props().isChecked).toBeTruthy();
+    expect(postTeamKeyTransactionsMock).toHaveBeenCalledTimes(1);
+  });
+
+  it('should be able to uncheck one team', async function () {
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/key-transactions/',
+      body: teams.map(({id}) => ({team: id})),
+    });
+
+    const deleteTeamKeyTransactionsMock = MockApiClient.addMockResponse(
+      {
+        method: 'DELETE',
+        url: '/organizations/org-slug/key-transactions/',
+        body: [],
+      },
+      {
+        predicate: (_, options) =>
+          options.method === 'DELETE' &&
+          options.query.project.length === 1 &&
+          options.query.project[0] === project.id &&
+          options.data.team.length === 1 &&
+          options.data.team[0] === teams[0].id &&
+          options.data.transaction === 'transaction',
+      }
+    );
+
+    const wrapper = mountWithTheme(
+      <TeamKeyTransaction
+        project={project.id}
+        organization={organization}
+        teams={teams}
+        transactionName="transaction"
+        title={TestTitle}
+      />
+    );
+
+    await tick();
+    wrapper.update();
+
+    wrapper.find('DropdownMenuItem').first().simulate('click');
+
+    await tick();
+    wrapper.update();
+
+    const entries = wrapper.find('DropdownMenuItem');
+    expect(entries.at(0).find('StyledCheckbox').props().isChecked).toBeFalsy();
+    expect(deleteTeamKeyTransactionsMock).toHaveBeenCalledTimes(1);
+  });
+
+  it('should be able to check all with my teams', async function () {
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/key-transactions/',
+      body: [],
+    });
+
+    const postTeamKeyTransactionsMock = MockApiClient.addMockResponse(
+      {
+        method: 'POST',
+        url: '/organizations/org-slug/key-transactions/',
+        body: [],
+      },
+      {
+        predicate: (_, options) =>
+          options.method === 'POST' &&
+          options.query.project.length === 1 &&
+          options.query.project[0] === project.id &&
+          options.data.team.length === 2 &&
+          options.data.team[0] === teams[0].id &&
+          options.data.team[1] === teams[1].id &&
+          options.data.transaction === 'transaction',
+      }
+    );
+
+    const wrapper = mountWithTheme(
+      <TeamKeyTransaction
+        project={project.id}
+        organization={organization}
+        teams={teams}
+        transactionName="transaction"
+        title={TestTitle}
+      />
+    );
+
+    await tick();
+    wrapper.update();
+
+    wrapper.find('DropdownMenuHeader').simulate('click');
+
+    await tick();
+    wrapper.update();
+
+    // header should be checked now
+    const header = wrapper.find('DropdownMenuHeader');
+    expect(header.find('StyledCheckbox').props().isChecked).toBeTruthy();
+    expect(header.find('StyledCheckbox').props().isIndeterminate).toBeFalsy();
+
+    // all teams should be checked now
+    const entries = wrapper.find('DropdownMenuItem');
+    entries.forEach(entry => {
+      expect(entry.find('StyledCheckbox').props().isChecked).toBeTruthy();
+    });
+    expect(postTeamKeyTransactionsMock).toHaveBeenCalledTimes(1);
+  });
+
+  it('should be able to uncheck all with my teams', async function () {
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/key-transactions/',
+      body: teams.map(({id}) => ({team: id})),
+    });
+
+    const deleteTeamKeyTransactionsMock = MockApiClient.addMockResponse(
+      {
+        method: 'DELETE',
+        url: '/organizations/org-slug/key-transactions/',
+        body: [],
+      },
+      {
+        predicate: (_, options) =>
+          options.method === 'DELETE' &&
+          options.query.project.length === 1 &&
+          options.query.project[0] === project.id &&
+          options.data.team.length === 2 &&
+          options.data.team[0] === teams[0].id &&
+          options.data.team[1] === teams[1].id &&
+          options.data.transaction === 'transaction',
+      }
+    );
+
+    const wrapper = mountWithTheme(
+      <TeamKeyTransaction
+        project={project.id}
+        organization={organization}
+        teams={teams}
+        transactionName="transaction"
+        title={TestTitle}
+      />
+    );
+
+    await tick();
+    wrapper.update();
+
+    wrapper.find('DropdownMenuHeader').simulate('click');
+
+    await tick();
+    wrapper.update();
+
+    // header should be unchecked now
+    const header = wrapper.find('DropdownMenuHeader');
+    expect(header.find('StyledCheckbox').props().isChecked).toBeFalsy();
+    expect(header.find('StyledCheckbox').props().isIndeterminate).toBeFalsy();
+
+    // all teams should be unchecked now
+    const entries = wrapper.find('DropdownMenuItem');
+    entries.forEach(entry => {
+      expect(entry.find('StyledCheckbox').props().isChecked).toBeFalsy();
+    });
+
+    expect(deleteTeamKeyTransactionsMock).toHaveBeenCalledTimes(1);
+  });
+});