Browse Source

feat(ui): Clean up UX for Admins in Team settings (#14193)

UX in Team Settings should depend more on if the user has access to the Team. How this is determined is by a combination of an organization's open membership policy and the user's role.
If they have access, then we can link them to the details page from the list view. If they attempt to access the details page, they will receive an error (and unlike before, will not be able to view anything on that page).

Admins should also be able to add members for teams they have access to, as well as modify team settings.

Closes PROD-102
Billy Vong 5 years ago
parent
commit
91855b9da4

+ 116 - 74
src/sentry/static/sentry/app/views/settings/organizationTeams/allTeamsRow.jsx

@@ -1,86 +1,109 @@
-import {Box} from 'grid-emotion';
 import {Link} from 'react-router';
 import PropTypes from 'prop-types';
 import React from 'react';
-import createReactClass from 'create-react-class';
+import styled from 'react-emotion';
 
+import {PanelItem} from 'app/components/panels';
 import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
 import {joinTeam, leaveTeam} from 'app/actionCreators/teams';
 import {t, tct, tn} from 'app/locale';
-import withApi from 'app/utils/withApi';
-import {PanelItem} from 'app/components/panels';
+import Button from 'app/components/button';
 import IdBadge from 'app/components/idBadge';
+import space from 'app/styles/space';
+import withApi from 'app/utils/withApi';
 
-// TODO(dcramer): this isnt great UX
-
-const AllTeamsRow = createReactClass({
-  displayName: 'AllTeamsRow',
-
-  propTypes: {
+class AllTeamsRow extends React.Component {
+  static propTypes = {
     api: PropTypes.object,
     urlPrefix: PropTypes.string.isRequired,
-    access: PropTypes.object.isRequired,
     organization: PropTypes.object.isRequired,
     team: PropTypes.object.isRequired,
     openMembership: PropTypes.bool.isRequired,
-  },
+  };
+
+  state = {
+    loading: false,
+    error: false,
+  };
+
+  handleRequestAccess = async () => {
+    const {team} = this.props;
+
+    try {
+      this.joinTeam({
+        successMessage: tct('You have requested access to [team]', {
+          team: `#${team.slug}`,
+        }),
+
+        errorMessage: tct('Unable to request access to [team]', {
+          team: `#${team.slug}`,
+        }),
+      });
+
+      // TODO: Ideally we would update team so that `isPending` is true
+    } catch (_err) {
+      // No need to do anything
+    }
+  };
 
-  getInitialState() {
-    return {
-      loading: false,
-      error: false,
-    };
-  },
+  handleJoinTeam = () => {
+    const {team} = this.props;
 
-  joinTeam() {
-    const {organization, team} = this.props;
+    this.joinTeam({
+      successMessage: tct('You have joined [team]', {
+        team: `#${team.slug}`,
+      }),
+      errorMessage: tct('Unable to join [team]', {
+        team: `#${team.slug}`,
+      }),
+    });
+  };
+
+  joinTeam = ({successMessage, errorMessage}) => {
+    const {api, organization, team} = this.props;
 
     this.setState({
       loading: true,
     });
 
-    joinTeam(
-      this.props.api,
-      {
-        orgId: organization.slug,
-        teamId: team.slug,
-      },
-      {
-        success: () => {
-          this.setState({
-            loading: false,
-            error: false,
-          });
-          addSuccessMessage(
-            tct('You have joined [team]', {
-              team: `#${team.slug}`,
-            })
-          );
+    return new Promise((resolve, reject) =>
+      joinTeam(
+        api,
+        {
+          orgId: organization.slug,
+          teamId: team.slug,
         },
-        error: () => {
-          this.setState({
-            loading: false,
-            error: true,
-          });
-          addErrorMessage(
-            tct('Unable to join [team]', {
-              team: `#${team.slug}`,
-            })
-          );
-        },
-      }
+        {
+          success: () => {
+            this.setState({
+              loading: false,
+              error: false,
+            });
+            addSuccessMessage(successMessage);
+            resolve();
+          },
+          error: () => {
+            this.setState({
+              loading: false,
+              error: true,
+            });
+            addErrorMessage(errorMessage);
+            reject(new Error('Unable to join team'));
+          },
+        }
+      )
     );
-  },
+  };
 
-  leaveTeam() {
-    const {organization, team} = this.props;
+  handleLeaveTeam = () => {
+    const {api, organization, team} = this.props;
 
     this.setState({
       loading: true,
     });
 
     leaveTeam(
-      this.props.api,
+      api,
       {
         orgId: organization.slug,
         teamId: team.slug,
@@ -110,10 +133,10 @@ const AllTeamsRow = createReactClass({
         },
       }
     );
-  },
+  };
 
   render() {
-    const {access, team, urlPrefix, openMembership} = this.props;
+    const {team, urlPrefix, openMembership} = this.props;
     const display = (
       <IdBadge
         team={team}
@@ -122,39 +145,58 @@ const AllTeamsRow = createReactClass({
       />
     );
 
+    // You can only view team details if you have access to team -- this should account
+    // for your role + org open memberhsip
+    const canViewTeam = team.hasAccess;
+
     return (
-      <PanelItem p={0} align="center">
-        <Box flex="1" p={2}>
-          {access.has('team:read') ? (
+      <TeamPanelItem>
+        <TeamNameWrapper>
+          {canViewTeam ? (
             <Link to={`${urlPrefix}teams/${team.slug}/`}>{display}</Link>
           ) : (
             display
           )}
-        </Box>
-        <Box p={2}>
+        </TeamNameWrapper>
+        <Spacer>
           {this.state.loading ? (
-            <a className="btn btn-default btn-sm btn-loading btn-disabled">...</a>
+            <Button size="small" disabled>
+              ...
+            </Button>
           ) : team.isMember ? (
-            <a className="leave-team btn btn-default btn-sm" onClick={this.leaveTeam}>
+            <Button size="small" onClick={this.handleLeaveTeam}>
               {t('Leave Team')}
-            </a>
+            </Button>
           ) : team.isPending ? (
-            <a className="btn btn-default btn-sm btn-disabled">{t('Request Pending')}</a>
+            <Button size="small" disabled>
+              {t('Request Pending')}
+            </Button>
           ) : openMembership ? (
-            <a className="btn btn-default btn-sm" onClick={this.joinTeam}>
+            <Button size="small" onClick={this.handleJoinTeam}>
               {t('Join Team')}
-            </a>
+            </Button>
           ) : (
-            <a className="btn btn-default btn-sm" onClick={this.joinTeam}>
+            <Button size="small" onClick={this.handleRequestAccess}>
               {t('Request Access')}
-            </a>
+            </Button>
           )}
-        </Box>
-      </PanelItem>
+        </Spacer>
+      </TeamPanelItem>
     );
-  },
-});
-
-export {AllTeamsRow};
+  }
+}
 
 export default withApi(AllTeamsRow);
+
+const TeamPanelItem = styled(PanelItem)`
+  padding: 0;
+  align-items: center;
+`;
+
+const Spacer = styled('div')`
+  padding: ${space(2)};
+`;
+
+const TeamNameWrapper = styled(Spacer)`
+  flex: 1;
+`;

+ 2 - 2
src/sentry/static/sentry/app/views/settings/organizationTeams/organizationTeams.jsx

@@ -38,7 +38,7 @@ class OrganizationTeams extends React.Component {
       return null;
     }
 
-    const canCreateTeams = new Set(organization.access).has('project:admin');
+    const canCreateTeams = access.has('project:admin');
 
     const action = (
       <Button
@@ -66,7 +66,7 @@ class OrganizationTeams extends React.Component {
     const otherTeams = allTeams.filter(team => !activeTeamIds.has(team.id));
 
     return (
-      <div data-test-id="team-list" className="team-list">
+      <div data-test-id="team-list">
         <SettingsPageHeader title={t('Teams')} action={action} />
         <Panel>
           <PanelHeader>{t('Your Teams')}</PanelHeader>

+ 89 - 20
src/sentry/static/sentry/app/views/settings/organizationTeams/teamDetails.jsx

@@ -1,12 +1,15 @@
 import {browserHistory} from 'react-router';
 import PropTypes from 'prop-types';
 import React from 'react';
-import createReactClass from 'create-react-class';
 import Reflux from 'reflux';
+import createReactClass from 'create-react-class';
+import styled from 'react-emotion';
 
-import {fetchTeamDetails} from 'app/actionCreators/teams';
-import {t} from 'app/locale';
-import withApi from 'app/utils/withApi';
+import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
+import {fetchTeamDetails, joinTeam} from 'app/actionCreators/teams';
+import {t, tct} from 'app/locale';
+import Alert from 'app/components/alert';
+import Button from 'app/components/button';
 import IdBadge from 'app/components/idBadge';
 import ListLink from 'app/components/links/listLink';
 import LoadingError from 'app/components/loadingError';
@@ -14,6 +17,7 @@ import LoadingIndicator from 'app/components/loadingIndicator';
 import NavTabs from 'app/components/navTabs';
 import TeamStore from 'app/stores/teamStore';
 import recreateRoute from 'app/utils/recreateRoute';
+import withApi from 'app/utils/withApi';
 
 const TeamDetails = createReactClass({
   displayName: 'TeamDetails',
@@ -35,23 +39,18 @@ const TeamDetails = createReactClass({
     };
   },
 
-  componentWillReceiveProps(nextProps) {
-    const params = this.props.params;
+  componentDidUpdate(prevProps) {
+    const {params} = this.props;
+
     if (
-      nextProps.params.teamId !== params.teamId ||
-      nextProps.params.orgId !== params.orgId
+      prevProps.params.teamId !== params.teamId ||
+      prevProps.params.orgId !== params.orgId
     ) {
-      this.setState(
-        {
-          loading: true,
-          error: false,
-        },
-        this.fetchData
-      );
+      this.fetchData();
     }
   },
 
-  onTeamStoreUpdate(...args) {
+  onTeamStoreUpdate() {
     const team = TeamStore.getBySlug(this.props.params.teamId);
     const loading = !TeamStore.initialized;
     const error = !loading && !team;
@@ -62,7 +61,54 @@ const TeamDetails = createReactClass({
     });
   },
 
+  handleRequestAccess() {
+    const {api, params} = this.props;
+    const {team} = this.state;
+
+    if (!team) {
+      return;
+    }
+
+    this.setState({
+      requesting: true,
+    });
+
+    joinTeam(
+      api,
+      {
+        orgId: params.orgId,
+        teamId: team.slug,
+      },
+      {
+        success: () => {
+          addSuccessMessage(
+            tct('You have requested access to [team]', {
+              team: `#${team.slug}`,
+            })
+          );
+          this.setState({
+            requesting: false,
+          });
+        },
+        error: () => {
+          addErrorMessage(
+            tct('Unable to request access to [team]', {
+              team: `#${team.slug}`,
+            })
+          );
+          this.setState({
+            requesting: false,
+          });
+        },
+      }
+    );
+  },
+
   fetchData() {
+    this.setState({
+      loading: true,
+      error: false,
+    });
     fetchTeamDetails(this.props.api, this.props.params);
   },
 
@@ -70,7 +116,7 @@ const TeamDetails = createReactClass({
     const team = this.state.team;
     if (data.slug !== team.slug) {
       const orgId = this.props.params.orgId;
-      browserHistory.push(`/organizations/${orgId}/teams/${data.slug}/settings/`);
+      browserHistory.replace(`/organizations/${orgId}/teams/${data.slug}/settings/`);
     } else {
       this.setState({
         team: {
@@ -87,7 +133,26 @@ const TeamDetails = createReactClass({
 
     if (this.state.loading) {
       return <LoadingIndicator />;
-    } else if (!team || this.state.error) {
+    } else if (!team || !team.hasAccess) {
+      return (
+        <Alert type="warning">
+          <h4>{t('You do not have access to this team')}</h4>
+
+          {team && (
+            <RequestAccessWrapper>
+              {tct('You may try to request access to [team]', {team: `#${team.slug}`})}
+              <Button
+                disabled={this.state.requesting || team.isPending}
+                size="small"
+                onClick={this.handleRequestAccess}
+              >
+                {team.isPending ? t('Request Pending') : t('Request Access')}
+              </Button>
+            </RequestAccessWrapper>
+          )}
+        </Alert>
+      );
+    } else if (this.state.error) {
       return <LoadingError onRetry={this.fetchData} />;
     }
 
@@ -115,6 +180,10 @@ const TeamDetails = createReactClass({
   },
 });
 
-export {TeamDetails};
-
 export default withApi(TeamDetails);
+
+const RequestAccessWrapper = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+`;

+ 85 - 69
src/sentry/static/sentry/app/views/settings/organizationTeams/teamMembers.jsx

@@ -1,31 +1,34 @@
-import PropTypes from 'prop-types';
 import {debounce} from 'lodash';
+import PropTypes from 'prop-types';
 import React from 'react';
 import styled from 'react-emotion';
 
-import withApi from 'app/utils/withApi';
-import IdBadge from 'app/components/idBadge';
+import {Panel, PanelHeader} from 'app/components/panels';
+import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
+import {joinTeam, leaveTeam} from 'app/actionCreators/teams';
+import {t} from 'app/locale';
 import Avatar from 'app/components/avatar';
 import Button from 'app/components/button';
-import Link from 'app/components/links/link';
 import DropdownAutoComplete from 'app/components/dropdownAutoComplete';
 import DropdownButton from 'app/components/dropdownButton';
+import EmptyMessage from 'app/views/settings/components/emptyMessage';
+import IdBadge from 'app/components/idBadge';
 import IndicatorStore from 'app/stores/indicatorStore';
-import {joinTeam, leaveTeam} from 'app/actionCreators/teams';
+import InlineSvg from 'app/components/inlineSvg';
+import Link from 'app/components/links/link';
 import LoadingError from 'app/components/loadingError';
 import LoadingIndicator from 'app/components/loadingIndicator';
-import {Panel, PanelHeader} from 'app/components/panels';
-import InlineSvg from 'app/components/inlineSvg';
-import EmptyMessage from 'app/views/settings/components/emptyMessage';
-import {t} from 'app/locale';
-import space from 'app/styles/space';
+import SentryTypes from 'app/sentryTypes';
 import overflowEllipsis from 'app/styles/overflowEllipsis';
+import space from 'app/styles/space';
+import withApi from 'app/utils/withApi';
+import withConfig from 'app/utils/withConfig';
 import withOrganization from 'app/utils/withOrganization';
-import SentryTypes from 'app/sentryTypes';
 
 class TeamMembers extends React.Component {
   static propTypes = {
     api: PropTypes.object.isRequired,
+    config: SentryTypes.Config.isRequired,
     organization: SentryTypes.Organization.isRequired,
   };
 
@@ -97,47 +100,50 @@ class TeamMembers extends React.Component {
     );
   }
 
-  fetchMembersRequest(query) {
-    const {orgId} = this.props.params;
-    return this.props.api.request(`/organizations/${orgId}/members/`, {
-      query: {
-        query,
-      },
-      success: data => {
-        this.setState({
-          orgMemberList: data,
-          dropdownBusy: false,
-        });
-      },
-      error: () => {
-        IndicatorStore.add(t('Unable to load organization members.'), 'error', {
-          duration: 2000,
-        });
-        this.setState({
-          dropdownBusy: false,
-        });
-      },
-    });
-  }
+  fetchMembersRequest = async query => {
+    const {params, api} = this.props;
+    const {orgId} = params;
 
-  fetchData = () => {
-    const params = this.props.params;
+    try {
+      const data = await api.requestPromise(`/organizations/${orgId}/members/`, {
+        query: {
+          query,
+        },
+      });
+      this.setState({
+        orgMemberList: data,
+        dropdownBusy: false,
+      });
+    } catch (_err) {
+      addErrorMessage(t('Unable to load organization members.'), {
+        duration: 2000,
+      });
 
-    this.props.api.request(`/teams/${params.orgId}/${params.teamId}/members/`, {
-      success: data => {
-        this.setState({
-          teamMemberList: data,
-          loading: false,
-          error: false,
-        });
-      },
-      error: () => {
-        this.setState({
-          loading: false,
-          error: true,
-        });
-      },
-    });
+      this.setState({
+        dropdownBusy: false,
+      });
+    }
+  };
+
+  fetchData = async () => {
+    const {api, params} = this.props;
+
+    try {
+      const data = await api.requestPromise(
+        `/teams/${params.orgId}/${params.teamId}/members/`
+      );
+      this.setState({
+        teamMemberList: data,
+        loading: false,
+        error: false,
+      });
+    } catch (err) {
+      this.setState({
+        loading: false,
+        error: true,
+        errorResponse: err,
+      });
+    }
 
     this.fetchMembersRequest('');
   };
@@ -169,15 +175,13 @@ class TeamMembers extends React.Component {
             error: false,
             teamMemberList: this.state.teamMemberList.concat([orgMember]),
           });
-          IndicatorStore.add(t('Successfully added member to team.'), 'success', {
-            duration: 2000,
-          });
+          addSuccessMessage(t('Successfully added member to team.'));
         },
         error: () => {
           this.setState({
             loading: false,
           });
-          IndicatorStore.add(t('Unable to add team member.'), 'error', {duration: 2000});
+          addErrorMessage(t('Unable to add team member.'));
         },
       }
     );
@@ -196,7 +200,12 @@ class TeamMembers extends React.Component {
   renderDropdown = access => {
     const {params} = this.props;
 
-    if (!access.has('org:write')) {
+    // You can add members if you have `org:write` or you have `team:admin` AND you belong to the team
+    // a parent "team details" request should determine your team membership, so this only view is rendered only
+    // when you are a member
+    const canAddMembers = access.has('org:write') || access.has('team:admin');
+
+    if (!canAddMembers) {
       return (
         <DropdownButton
           disabled={true}
@@ -245,7 +254,7 @@ class TeamMembers extends React.Component {
         busy={this.state.dropdownBusy}
         onClose={() => this.debouncedFetchMembersRequest('')}
       >
-        {({isOpen, selectedItem}) => (
+        {({isOpen}) => (
           <DropdownButton isOpen={isOpen} size="xsmall">
             {t('Add Member')}
           </DropdownButton>
@@ -256,7 +265,11 @@ class TeamMembers extends React.Component {
 
   removeButton = member => {
     return (
-      <Button size="small" onClick={this.removeMember.bind(this, member)}>
+      <Button
+        size="small"
+        onClick={this.removeMember.bind(this, member)}
+        label={t('Remove')}
+      >
         <InlineSvg
           src="icon-circle-subtract"
           size="1.25em"
@@ -274,9 +287,10 @@ class TeamMembers extends React.Component {
       return <LoadingError onRetry={this.fetchData} />;
     }
 
-    const {params, organization} = this.props;
-
+    const {params, organization, config} = this.props;
     const access = new Set(organization.access);
+    const isOrgAdmin = access.has('org:write');
+    const isTeamAdmin = access.has('team:admin');
 
     return (
       <Panel>
@@ -285,15 +299,19 @@ class TeamMembers extends React.Component {
           <div style={{textTransform: 'none'}}>{this.renderDropdown(access)}</div>
         </PanelHeader>
         {this.state.teamMemberList.length ? (
-          this.state.teamMemberList.map(member => (
-            <StyledMemberContainer key={member.id}>
-              <IdBadge avatarSize={36} member={member} useLink orgId={params.orgId} />
-              {access.has('org:write') && this.removeButton(member)}
-            </StyledMemberContainer>
-          ))
+          this.state.teamMemberList.map(member => {
+            const isSelf = member.email === config.user.email;
+            const canRemoveMember = isOrgAdmin || isTeamAdmin || isSelf;
+            return (
+              <StyledMemberContainer key={member.id}>
+                <IdBadge avatarSize={36} member={member} useLink orgId={params.orgId} />
+                {canRemoveMember && this.removeButton(member)}
+              </StyledMemberContainer>
+            );
+          })
         ) : (
           <EmptyMessage icon="icon-user" size="large">
-            {t('Your Team is Empty')}
+            {t('This team has no members')}
           </EmptyMessage>
         )}
       </Panel>
@@ -341,6 +359,4 @@ const StyledCreateMemberLink = styled(Link)`
   text-transform: none;
 `;
 
-export {TeamMembers};
-
-export default withApi(withOrganization(TeamMembers));
+export default withConfig(withApi(withOrganization(TeamMembers)));

+ 5 - 6
src/sentry/static/sentry/app/views/settings/organizationTeams/teamSettings/index.jsx

@@ -45,21 +45,20 @@ export default class TeamSettings extends AsyncView {
     return [];
   }
 
-  handleSubmitSuccess = (resp, model, id, change) => {
+  handleSubmitSuccess = (resp, model, id) => {
     updateTeamSuccess(resp.slug, resp);
     if (id === 'slug') {
       addSuccessMessage(t('Team name changed'));
-      this.props.router.push(
+      this.props.router.replace(
         `/settings/${this.props.params.orgId}/teams/${model.getValue(id)}/settings/`
       );
       this.setState({loading: true});
     }
   };
 
-  handleRemoveTeam = () => {
-    removeTeam(this.api, this.props.params).then(data => {
-      this.props.router.push(`/settings/${this.props.params.orgId}/teams/`);
-    });
+  handleRemoveTeam = async () => {
+    await removeTeam(this.api, this.props.params);
+    this.props.router.replace(`/settings/${this.props.params.orgId}/teams/`);
   };
 
   renderBody() {

+ 1 - 1
tests/acceptance/test_create_team.py

@@ -29,7 +29,7 @@ class CreateTeamTest(AcceptanceTestCase):
 
     def test_create(self):
         self.browser.get(self.path)
-        self.browser.wait_until('.team-list')
+        self.browser.wait_until_test_id('team-list')
 
         # Open the modal
         self.browser.click('button[aria-label="Create Team"]')

+ 2 - 2
tests/acceptance/test_teams_list.py

@@ -33,11 +33,11 @@ class TeamsListTest(AcceptanceTestCase):
         self.project.update(first_event=timezone.now())
         self.browser.get(self.path)
         self.browser.wait_until_not('.loading-indicator')
-        self.browser.wait_until('.team-list')
+        self.browser.wait_until_test_id('team-list')
         self.browser.snapshot('organization teams list')
 
         # team details link
-        self.browser.click('.team-list a[href]:first-child')
+        self.browser.click('[data-test-id="team-list"] a[href]:first-child')
         self.browser.wait_until_not('.loading-indicator')
         self.browser.snapshot('organization team - members list')
 

+ 0 - 41
tests/js/spec/views/__snapshots__/teamMembers.spec.jsx.snap

@@ -1,41 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`TeamMembers renders 1`] = `
-<WithOrganizationMockWrapper
-  api={Client {}}
-  organization={
-    Object {
-      "access": Array [
-        "org:read",
-        "org:write",
-        "org:admin",
-        "org:integrations",
-        "project:read",
-        "project:write",
-        "project:admin",
-        "team:read",
-        "team:write",
-        "team:admin",
-      ],
-      "features": Array [],
-      "id": "3",
-      "name": "Organization Name",
-      "onboardingTasks": Array [],
-      "projects": Array [],
-      "scrapeJavaScript": true,
-      "slug": "org-slug",
-      "status": Object {
-        "id": "active",
-        "name": "active",
-      },
-      "teams": Array [],
-    }
-  }
-  params={
-    Object {
-      "orgId": "org-slug",
-      "teamId": "team-slug",
-    }
-  }
-/>
-`;

+ 93 - 29
tests/js/spec/views/settings/organizationTeams.spec.jsx

@@ -1,8 +1,9 @@
 import React from 'react';
-import {mount} from 'enzyme';
 
-import OrganizationTeams from 'app/views/settings/organizationTeams/organizationTeams';
+import {initializeOrg} from 'app-test/helpers/initializeOrg';
+import {mount} from 'enzyme';
 import {openCreateTeamModal} from 'app/actionCreators/modal';
+import OrganizationTeams from 'app/views/settings/organizationTeams/organizationTeams';
 import recreateRoute from 'app/utils/recreateRoute';
 
 recreateRoute.mockReturnValue('');
@@ -12,8 +13,6 @@ jest.mock('app/actionCreators/modal', () => ({
 }));
 
 describe('OrganizationTeams', function() {
-  const org = TestStubs.Organization();
-  const project = TestStubs.Project();
   beforeEach(function() {
     MockApiClient.addMockResponse({
       url: '/organizations/org-slug/stats/',
@@ -21,30 +20,95 @@ describe('OrganizationTeams', function() {
     });
   });
 
-  it('opens "create team modal" when creating a new team from header', function() {
-    const wrapper = mount(
-      <OrganizationTeams
-        params={{orgId: org.slug, projectId: project.slug}}
-        routes={[]}
-        allTeams={[TestStubs.Team()]}
-        access={new Set(['org:write'])}
-        features={new Set([])}
-        activeTeams={[]}
-        organization={org}
-      />,
-      TestStubs.routerContext()
-    );
-
-    // Click "Create Team" in Panel Header
-    wrapper.find('SettingsPageHeading Button').simulate('click');
-
-    // action creator to open "create team modal" is called
-    expect(openCreateTeamModal).toHaveBeenCalledWith(
-      expect.objectContaining({
-        organization: expect.objectContaining({
-          slug: org.slug,
-        }),
-      })
-    );
+  describe('Open Membership', function() {
+    const {organization, project, routerContext} = initializeOrg({
+      organization: {
+        openMembership: true,
+      },
+    });
+    const teams = [TestStubs.Team()];
+
+    const createWrapper = props =>
+      mount(
+        <OrganizationTeams
+          params={{orgId: organization.slug, projectId: project.slug}}
+          routes={[]}
+          features={new Set(['open-membership'])}
+          access={new Set(['project:admin'])}
+          allTeams={teams}
+          activeTeams={[]}
+          organization={organization}
+          {...props}
+        />,
+        routerContext
+      );
+
+    it('opens "create team modal" when creating a new team from header', async function() {
+      const wrapper = createWrapper();
+
+      // Click "Create Team" in Panel Header
+      wrapper.find('SettingsPageHeading Button').simulate('click');
+
+      // action creator to open "create team modal" is called
+      expect(openCreateTeamModal).toHaveBeenCalledWith(
+        expect.objectContaining({
+          organization: expect.objectContaining({
+            slug: organization.slug,
+          }),
+        })
+      );
+    });
+
+    it('can join team and have link to details', function() {
+      const wrapper = createWrapper({
+        allTeams: [TestStubs.Team({hasAccess: true, isMember: false})],
+        access: new Set([]),
+      });
+      expect(wrapper.find('button[aria-label="Join Team"]')).toHaveLength(1);
+
+      // Should also link to details
+      expect(wrapper.find('Link')).toHaveLength(1);
+    });
+  });
+
+  describe('Closed Membership', function() {
+    const {organization, project, routerContext} = initializeOrg({
+      organization: {
+        openMembership: false,
+      },
+    });
+    const createWrapper = props =>
+      mount(
+        <OrganizationTeams
+          params={{orgId: organization.slug, projectId: project.slug}}
+          routes={[]}
+          features={new Set([])}
+          access={new Set([])}
+          allTeams={[]}
+          activeTeams={[]}
+          organization={organization}
+          {...props}
+        />,
+        routerContext
+      );
+
+    it('can request access to team and does not have link to details', function() {
+      const wrapper = createWrapper({
+        allTeams: [TestStubs.Team({hasAccess: false, isMember: false})],
+        access: new Set([]),
+      });
+      expect(wrapper.find('button[aria-label="Request Access"]')).toHaveLength(1);
+
+      // Should also not link to details because of lack of access
+      expect(wrapper.find('Link')).toHaveLength(0);
+    });
+
+    it('can leave team when you are a member', function() {
+      const wrapper = createWrapper({
+        allTeams: [TestStubs.Team({hasAccess: true, isMember: true})],
+        access: new Set([]),
+      });
+      expect(wrapper.find('button[aria-label="Leave Team"]')).toHaveLength(1);
+    });
   });
 });

+ 74 - 14
tests/js/spec/views/teamMembers.spec.jsx

@@ -1,41 +1,45 @@
 import React from 'react';
-import {shallow, mount} from 'enzyme';
 
 import {Client} from 'app/api';
+import {initializeOrg} from 'app-test/helpers/initializeOrg';
+import {mount} from 'enzyme';
 import TeamMembers from 'app/views/settings/organizationTeams/teamMembers';
 
 describe('TeamMembers', function() {
-  const routerContext = TestStubs.routerContext();
-  const org = routerContext.context.organization;
+  const {organization, routerContext} = initializeOrg();
   const team = TestStubs.Team();
   const members = TestStubs.Members();
 
   beforeEach(function() {
     Client.clearMockResponses();
     Client.addMockResponse({
-      url: `/organizations/${org.slug}/members/`,
+      url: `/organizations/${organization.slug}/members/`,
       method: 'GET',
       body: members,
     });
     Client.addMockResponse({
-      url: `/teams/${org.slug}/${team.slug}/members/`,
+      url: `/teams/${organization.slug}/${team.slug}/members/`,
       method: 'GET',
       body: members,
     });
   });
 
-  it('renders', function() {
-    const wrapper = shallow(
-      <TeamMembers params={{orgId: org.slug, teamId: team.slug}} organization={org} />,
+  it('renders', async function() {
+    const wrapper = mount(
+      <TeamMembers
+        params={{orgId: organization.slug, teamId: team.slug}}
+        organization={organization}
+      />,
       routerContext
     );
-    expect(wrapper).toMatchSnapshot();
+    await tick();
+    wrapper.update();
   });
 
-  it('can remove a team', function() {
-    const endpoint = `/organizations/${org.slug}/members/${members[0].id}/teams/${
-      team.slug
-    }/`;
+  it('can remove member from team', async function() {
+    const endpoint = `/organizations/${organization.slug}/members/${
+      members[0].id
+    }/teams/${team.slug}/`;
     const mock = Client.addMockResponse({
       url: endpoint,
       method: 'DELETE',
@@ -43,10 +47,16 @@ describe('TeamMembers', function() {
     });
 
     const wrapper = mount(
-      <TeamMembers params={{orgId: org.slug, teamId: team.slug}} organization={org} />,
+      <TeamMembers
+        params={{orgId: organization.slug, teamId: team.slug}}
+        organization={organization}
+      />,
       routerContext
     );
 
+    await tick();
+    wrapper.update();
+
     expect(mock).not.toHaveBeenCalled();
 
     wrapper
@@ -61,4 +71,54 @@ describe('TeamMembers', function() {
       })
     );
   });
+
+  it('can only remove self from team', async function() {
+    const me = TestStubs.Member({
+      id: '123',
+      email: 'foo@example.com',
+    });
+    Client.addMockResponse({
+      url: `/teams/${organization.slug}/${team.slug}/members/`,
+      method: 'GET',
+      body: [...members, me],
+    });
+
+    const endpoint = `/organizations/${organization.slug}/members/${me.id}/teams/${
+      team.slug
+    }/`;
+    const mock = Client.addMockResponse({
+      url: endpoint,
+      method: 'DELETE',
+      statusCode: 200,
+    });
+    const organizationMember = TestStubs.Organization({
+      access: [],
+    });
+
+    const wrapper = mount(
+      <TeamMembers
+        params={{orgId: organization.slug, teamId: team.slug}}
+        organization={organizationMember}
+      />,
+      routerContext
+    );
+
+    await tick();
+    wrapper.update();
+
+    expect(mock).not.toHaveBeenCalled();
+
+    expect(wrapper.find('IdBadge')).toHaveLength(members.length + 1);
+
+    // Can only remove self
+    expect(wrapper.find('button[aria-label="Remove"]')).toHaveLength(1);
+
+    wrapper.find('button[aria-label="Remove"]').simulate('click');
+    expect(mock).toHaveBeenCalledWith(
+      endpoint,
+      expect.objectContaining({
+        method: 'DELETE',
+      })
+    );
+  });
 });

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