Browse Source

feat(team-roles): Add Team Roles to teamMembers page (#36046)

Danny Lee 2 years ago
parent
commit
15829df205

+ 2 - 0
fixtures/js-stubs/member.js

@@ -5,6 +5,8 @@ export function Member(params = {}) {
     id: '1',
     email: 'sentry1@test.com',
     name: 'Sentry 1 Name',
+    orgRole: 'member',
+    teamRoles: [],
     role: 'member',
     roleName: 'Member',
     pending: false,

+ 6 - 0
fixtures/js-stubs/members.js

@@ -7,6 +7,8 @@ export function Members(params = []) {
       id: '2',
       name: 'Sentry 2 Name',
       email: 'sentry2@test.com',
+      orgRole: 'member',
+      teamRoles: [],
       role: 'member',
       roleName: 'Member',
       pending: true,
@@ -19,6 +21,8 @@ export function Members(params = []) {
       id: '3',
       name: 'Sentry 3 Name',
       email: 'sentry3@test.com',
+      orgRole: 'owner',
+      teamRoles: [],
       role: 'owner',
       roleName: 'Owner',
       pending: false,
@@ -37,6 +41,8 @@ export function Members(params = []) {
       id: '4',
       name: 'Sentry 4 Name',
       email: 'sentry4@test.com',
+      orgRole: 'owner',
+      teamRoles: [],
       role: 'owner',
       roleName: 'Owner',
       pending: false,

+ 5 - 0
fixtures/js-stubs/organization.js

@@ -1,3 +1,5 @@
+import {OrgRoleList, TeamRoleList} from './roleList';
+
 export function Organization(params = {}) {
   return {
     id: '3',
@@ -30,5 +32,8 @@ export function Organization(params = {}) {
     teams: [],
     projects: [],
     ...params,
+
+    orgRoleList: OrgRoleList(),
+    teamRoleList: TeamRoleList(),
   };
 }

+ 1 - 1
static/app/components/roleSelectControl.tsx

@@ -17,7 +17,7 @@ type Props = Omit<ControlProps<OptionType>, 'onChange' | 'value'> & {
    * Narrower type than SelectControl because there is no empty value
    */
   onChange?: (value: OptionType) => void;
-  value?: string;
+  value?: string | null;
 };
 
 function RoleSelectControl({roles, disableUnallowed, ...props}: Props) {

+ 8 - 1
static/app/types/organization.tsx

@@ -131,10 +131,17 @@ export type Member = {
     teamSlug: string;
   }[];
   teams: string[]; // # Deprecated, use teamRoles
-
   user: User;
 };
 
+/**
+ * Returned from TeamMembersEndpoint
+ */
+export type TeamMember = Member & {
+  teamRole?: string | null;
+  teamSlug?: string;
+};
+
 /**
  * Minimal organization shape used on shared issue views.
  */

+ 66 - 56
static/app/views/settings/organizationTeams/teamMembers.tsx

@@ -11,24 +11,24 @@ import {
 import {joinTeam, leaveTeam} from 'sentry/actionCreators/teams';
 import {Client} from 'sentry/api';
 import UserAvatar from 'sentry/components/avatar/userAvatar';
-import Button from 'sentry/components/button';
 import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
 import {Item} from 'sentry/components/dropdownAutoComplete/types';
 import DropdownButton from 'sentry/components/dropdownButton';
-import IdBadge from 'sentry/components/idBadge';
 import Link from 'sentry/components/links/link';
 import LoadingError from 'sentry/components/loadingError';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
-import {Panel, PanelHeader, PanelItem} from 'sentry/components/panels';
-import {IconSubtract, IconUser} from 'sentry/icons';
+import {Panel, PanelHeader} from 'sentry/components/panels';
+import {IconUser} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {Config, Member, Organization} from 'sentry/types';
+import {Config, Member, Organization, TeamMember} from 'sentry/types';
 import withApi from 'sentry/utils/withApi';
 import withConfig from 'sentry/utils/withConfig';
 import withOrganization from 'sentry/utils/withOrganization';
 import EmptyMessage from 'sentry/views/settings/components/emptyMessage';
 
+import TeamMembersRow from './teamMembersRow';
+
 type RouteParams = {
   orgId: string;
   teamId: string;
@@ -45,7 +45,7 @@ type State = {
   error: boolean;
   loading: boolean;
   orgMemberList: Member[];
-  teamMemberList: Member[];
+  teamMemberList: TeamMember[];
 };
 
 class TeamMembers extends Component<Props, State> {
@@ -83,30 +83,6 @@ class TeamMembers extends Component<Props, State> {
     200
   );
 
-  removeMember(member: Member) {
-    const {params} = this.props;
-    leaveTeam(
-      this.props.api,
-      {
-        orgId: params.orgId,
-        teamId: params.teamId,
-        memberId: member.id,
-      },
-      {
-        success: () => {
-          this.setState({
-            teamMemberList: this.state.teamMemberList.filter(m => m.id !== member.id),
-          });
-          addSuccessMessage(t('Successfully removed member from team.'));
-        },
-        error: () =>
-          addErrorMessage(
-            t('There was an error while trying to remove a member from the team.')
-          ),
-      }
-    );
-  }
-
   fetchMembersRequest = async (query: string) => {
     const {params, api} = this.props;
     const {orgId} = params;
@@ -178,7 +154,7 @@ class TeamMembers extends Component<Props, State> {
           this.setState({
             loading: false,
             error: false,
-            teamMemberList: this.state.teamMemberList.concat([orgMember]),
+            teamMemberList: this.state.teamMemberList.concat([orgMember as TeamMember]),
           });
           addSuccessMessage(t('Successfully added member to team.'));
         },
@@ -192,6 +168,55 @@ class TeamMembers extends Component<Props, State> {
     );
   };
 
+  removeTeamMember = (member: Member) => {
+    const {params} = this.props;
+    leaveTeam(
+      this.props.api,
+      {
+        orgId: params.orgId,
+        teamId: params.teamId,
+        memberId: member.id,
+      },
+      {
+        success: () => {
+          this.setState({
+            teamMemberList: this.state.teamMemberList.filter(m => m.id !== member.id),
+          });
+          addSuccessMessage(t('Successfully removed member from team.'));
+        },
+        error: () =>
+          addErrorMessage(
+            t('There was an error while trying to remove a member from the team.')
+          ),
+      }
+    );
+  };
+
+  updateTeamMemberRole = (member: Member, newRole: string) => {
+    const {orgId, teamId} = this.props.params;
+    const endpoint = `/organizations/${orgId}/members/${member.id}/teams/${teamId}/`;
+
+    this.props.api.request(endpoint, {
+      method: 'PUT',
+      data: {teamRole: newRole},
+      success: data => {
+        const teamMemberList: any = [...this.state.teamMemberList];
+        const i = teamMemberList.findIndex(m => m.id === member.id);
+        teamMemberList[i] = {
+          ...member,
+          teamRole: data.teamRole,
+        };
+        this.setState({teamMemberList});
+        addSuccessMessage(t('Successfully changed role for team member.'));
+      },
+      error: () => {
+        addErrorMessage(
+          t('There was an error while trying to change the roles for a team member.')
+        );
+      },
+    });
+  };
+
   /**
    * We perform an API request to support orgs with > 100 members (since that's the max API returns)
    *
@@ -266,19 +291,6 @@ class TeamMembers extends Component<Props, State> {
     );
   }
 
-  removeButton(member: Member) {
-    return (
-      <Button
-        size="sm"
-        icon={<IconSubtract size="xs" isCircled />}
-        onClick={() => this.removeMember(member)}
-        aria-label={t('Remove')}
-      >
-        {t('Remove')}
-      </Button>
-    );
-  }
-
   render() {
     if (this.state.loading) {
       return <LoadingIndicator />;
@@ -288,7 +300,7 @@ class TeamMembers extends Component<Props, State> {
       return <LoadingError onRetry={this.fetchData} />;
     }
 
-    const {params, organization, config} = this.props;
+    const {organization, config} = this.props;
     const {access} = organization;
     const hasWriteAccess = access.includes('org:write') || access.includes('team:admin');
 
@@ -300,13 +312,16 @@ class TeamMembers extends Component<Props, State> {
         </PanelHeader>
         {this.state.teamMemberList.length ? (
           this.state.teamMemberList.map(member => {
-            const isSelf = member.email === config.user.email;
-            const canRemoveMember = hasWriteAccess || isSelf;
             return (
-              <StyledMemberContainer key={member.id}>
-                <IdBadge avatarSize={36} member={member} useLink orgId={params.orgId} />
-                {canRemoveMember && this.removeButton(member)}
-              </StyledMemberContainer>
+              <TeamMembersRow
+                key={member.id}
+                hasWriteAccess={hasWriteAccess}
+                member={member}
+                organization={organization}
+                removeMember={this.removeTeamMember}
+                updateMemberRole={this.updateTeamMemberRole}
+                user={config.user}
+              />
             );
           })
         ) : (
@@ -319,11 +334,6 @@ class TeamMembers extends Component<Props, State> {
   }
 }
 
-const StyledMemberContainer = styled(PanelItem)`
-  justify-content: space-between;
-  align-items: center;
-`;
-
 const StyledUserListElement = styled('div')`
   display: grid;
   grid-template-columns: max-content 1fr;

+ 157 - 0
static/app/views/settings/organizationTeams/teamMembersRow.tsx

@@ -0,0 +1,157 @@
+import styled from '@emotion/styled';
+
+import Button from 'sentry/components/button';
+import IdBadge from 'sentry/components/idBadge';
+import {PanelItem} from 'sentry/components/panels';
+import RoleSelectControl from 'sentry/components/roleSelectControl';
+import {IconSubtract} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Member, Organization, TeamMember, User} from 'sentry/types';
+import {
+  hasOrgRoleOverwrite,
+  RoleOverwriteIcon,
+} from 'sentry/views/settings/organizationTeams/roleOverwriteWarning';
+
+const TeamMembersRow = (props: {
+  hasWriteAccess: boolean;
+  member: TeamMember;
+  organization: Organization;
+  removeMember: (member: Member) => void;
+  updateMemberRole: (member: Member, newRole: string) => void;
+  user: User;
+}) => {
+  const {organization, member, user, hasWriteAccess, removeMember, updateMemberRole} =
+    props;
+
+  return (
+    <TeamRolesPanelItem key={member.id}>
+      <div>
+        <IdBadge avatarSize={36} member={member} useLink orgId={organization.slug} />
+      </div>
+      <div>
+        <TeamRoleSelect
+          hasWriteAccess={hasWriteAccess}
+          updateMemberRole={updateMemberRole}
+          organization={organization}
+          member={member}
+        />
+      </div>
+      <div>
+        <RemoveButton
+          hasWriteAccess={hasWriteAccess}
+          onClick={() => removeMember(member)}
+          member={member}
+          user={user}
+        />
+      </div>
+    </TeamRolesPanelItem>
+  );
+};
+
+const TeamRoleSelect = (props: {
+  hasWriteAccess: boolean;
+  member: TeamMember;
+  organization: Organization;
+  updateMemberRole: (member: TeamMember, newRole: string) => void;
+}) => {
+  const {hasWriteAccess, organization, member, updateMemberRole} = props;
+  const {orgRoleList, teamRoleList, features} = organization;
+  if (!features.includes('team-roles')) {
+    return null;
+  }
+
+  const {orgRole: orgRoleId} = member;
+  const orgRole = orgRoleList.find(r => r.id === orgRoleId);
+
+  const teamRoleId = member.teamRole || orgRole?.minimumTeamRole;
+  const teamRole = teamRoleList.find(r => r.id === teamRoleId) || teamRoleList[0];
+
+  if (
+    !hasWriteAccess ||
+    hasOrgRoleOverwrite({orgRole: orgRoleId, orgRoleList, teamRoleList})
+  ) {
+    return (
+      <RoleName>
+        {teamRole.name}
+        <IconWrapper>
+          <RoleOverwriteIcon
+            orgRole={orgRoleId}
+            orgRoleList={orgRoleList}
+            teamRoleList={teamRoleList}
+          />
+        </IconWrapper>
+      </RoleName>
+    );
+  }
+
+  return (
+    <RoleSelectWrapper>
+      <RoleSelectControl
+        roles={teamRoleList}
+        value={teamRole.id}
+        onChange={option => updateMemberRole(member, option.value)}
+        disableUnallowed
+      />
+    </RoleSelectWrapper>
+  );
+};
+
+const RemoveButton = (props: {
+  hasWriteAccess: boolean;
+  member: TeamMember;
+  onClick: () => void;
+  user: User;
+}) => {
+  const {member, user, hasWriteAccess, onClick} = props;
+
+  const isSelf = member.email === user.email;
+  const canRemoveMember = hasWriteAccess || isSelf;
+  if (!canRemoveMember) {
+    return null;
+  }
+
+  return (
+    <Button
+      size="xs"
+      disabled={!canRemoveMember}
+      icon={<IconSubtract size="xs" isCircled />}
+      onClick={onClick}
+      aria-label={t('Remove')}
+    >
+      {t('Remove')}
+    </Button>
+  );
+};
+
+const RoleName = styled('div')`
+  display: flex;
+  align-items: center;
+`;
+const IconWrapper = styled('div')`
+  height: ${space(2)};
+  margin-left: ${space(1)};
+`;
+
+const RoleSelectWrapper = styled('div')`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > div:first-child {
+    flex-grow: 1;
+  }
+`;
+
+const TeamRolesPanelItem = styled(PanelItem)`
+  display: grid;
+  grid-template-columns: minmax(120px, 4fr) minmax(120px, 2fr) minmax(100px, 1fr);
+  gap: ${space(2)};
+  align-items: center;
+
+  > div:last-child {
+    margin-left: auto;
+  }
+`;
+
+export default TeamMembersRow;

+ 52 - 3
tests/js/spec/views/team/teamMembers.spec.jsx

@@ -174,9 +174,7 @@ describe('TeamMembers', function () {
       url: `/organizations/${organization.slug}/members/${me.id}/teams/${team.slug}/`,
       method: 'DELETE',
     });
-    const organizationMember = TestStubs.Organization({
-      access: [],
-    });
+    const organizationMember = TestStubs.Organization({access: []});
 
     render(
       <TeamMembers
@@ -198,4 +196,55 @@ describe('TeamMembers', function () {
     userEvent.click(screen.getByRole('button', {name: 'Remove'}));
     expect(deleteMock).toHaveBeenCalled();
   });
+
+  it('does not renders team-level roles', async function () {
+    const me = TestStubs.Member({
+      id: '123',
+      email: 'foo@example.com',
+      role: 'owner',
+    });
+    Client.addMockResponse({
+      url: `/teams/${organization.slug}/${team.slug}/members/`,
+      method: 'GET',
+      body: [...members, me],
+    });
+
+    await render(
+      <TeamMembers
+        params={{orgId: organization.slug, teamId: team.slug}}
+        organization={organization}
+      />
+    );
+
+    const admins = screen.queryByText('Team Admin');
+    expect(admins).not.toBeInTheDocument();
+    const contributors = screen.queryByText('Team Contributor');
+    expect(contributors).not.toBeInTheDocument();
+  });
+
+  it('renders team-level roles with flag', async function () {
+    const manager = TestStubs.Member({
+      id: '123',
+      email: 'foo@example.com',
+      orgRole: 'manager',
+    });
+    Client.addMockResponse({
+      url: `/teams/${organization.slug}/${team.slug}/members/`,
+      method: 'GET',
+      body: [...members, manager],
+    });
+
+    const orgWithTeamRoles = TestStubs.Organization({features: ['team-roles']});
+
+    await render(
+      <TeamMembers
+        params={{orgId: orgWithTeamRoles.slug, teamId: team.slug}}
+        organization={orgWithTeamRoles}
+      />
+    );
+    const admins = screen.queryAllByText('Team Admin');
+    expect(admins).toHaveLength(3);
+    const contributors = screen.queryAllByText('Contributor');
+    expect(contributors).toHaveLength(2);
+  });
 });