Browse Source

feat(roles): Team-roles for Team Members page (#48317)

http://default.dev.getsentry.net:8000/settings/teams/captain-planet/members/
Danny Lee 1 year ago
parent
commit
34eeed0e9b

+ 93 - 0
static/app/components/teamRoleSelect.tsx

@@ -0,0 +1,93 @@
+import styled from '@emotion/styled';
+
+import {ControlProps} from 'sentry/components/forms/controls/selectControl';
+import RoleSelectControl from 'sentry/components/roleSelectControl';
+import {space} from 'sentry/styles/space';
+import {Organization, Team, TeamMember, TeamRole} from 'sentry/types';
+import {getEffectiveOrgRole} from 'sentry/utils/orgRole';
+import {
+  hasOrgRoleOverwrite,
+  RoleOverwriteIcon,
+} from 'sentry/views/settings/organizationTeams/roleOverwriteWarning';
+
+interface Props {
+  member: TeamMember;
+  onChangeTeamRole: (newRole: TeamRole['id'] | string) => void;
+  organization: Organization;
+  team: Team;
+  disabled?: boolean;
+  size?: ControlProps['size'];
+}
+
+function TeamRoleSelect({
+  organization,
+  team,
+  member,
+  onChangeTeamRole,
+  disabled,
+  size,
+}: Props) {
+  const {orgRoleList, teamRoleList, features} = organization;
+  if (!features.includes('team-roles')) {
+    return null;
+  }
+
+  // Determine the org-role, including if the current team has an org role
+  // and adding the user to the current team changes their minimum team-role
+  const possibleOrgRoles = [member.orgRole];
+  if (member.orgRolesFromTeams && member.orgRolesFromTeams.length > 0) {
+    possibleOrgRoles.push(member.orgRolesFromTeams[0].role.id);
+  }
+  if (team.orgRole) {
+    possibleOrgRoles.push(team.orgRole);
+  }
+  const effectiveOrgRole = getEffectiveOrgRole(possibleOrgRoles, orgRoleList);
+
+  // If the member's org-role has elevated permission, their team-role will
+  // inherit scopes from it
+  if (hasOrgRoleOverwrite({orgRole: effectiveOrgRole?.id, orgRoleList, teamRoleList})) {
+    const effectiveTeamRole = teamRoleList.find(
+      r => r.id === effectiveOrgRole?.minimumTeamRole
+    );
+
+    return (
+      <RoleName>
+        {effectiveTeamRole?.name || effectiveOrgRole?.minimumTeamRole}
+        <IconWrapper>
+          <RoleOverwriteIcon
+            orgRole={effectiveOrgRole?.id}
+            orgRoleList={orgRoleList}
+            teamRoleList={teamRoleList}
+          />
+        </IconWrapper>
+      </RoleName>
+    );
+  }
+
+  const teamRoleId =
+    member.teamRole || // From TeamMemberEndpoint
+    member.teamRoles.find(tr => tr.teamSlug === team.slug)?.role; // From OrgMemberDetailEndpoint
+  const teamRole = teamRoleList.find(r => r.id === teamRoleId) || teamRoleList[0];
+
+  return (
+    <RoleSelectControl
+      disabled={disabled}
+      disableUnallowed={false}
+      roles={teamRoleList}
+      value={teamRole.id}
+      onChange={option => onChangeTeamRole(option.value)}
+      size={size}
+    />
+  );
+}
+
+export default TeamRoleSelect;
+
+const RoleName = styled('div')`
+  display: flex;
+  align-items: center;
+`;
+const IconWrapper = styled('div')`
+  height: ${space(2)};
+  margin-left: ${space(1)};
+`;

+ 4 - 1
static/app/utils/orgRole.tsx

@@ -1,6 +1,9 @@
 import {OrgRole} from 'sentry/types';
 
-export function getEffectiveOrgRole(memberOrgRoles: string[], orgRoleList: OrgRole[]) {
+export function getEffectiveOrgRole(
+  memberOrgRoles: string[],
+  orgRoleList: OrgRole[]
+): OrgRole {
   const orgRoleMap = orgRoleList.reduce((acc, role, index) => {
     acc[role.id] = {index, role};
     return acc;

+ 0 - 404
static/app/views/settings/components/teamSelect.tsx

@@ -1,404 +0,0 @@
-import React from 'react';
-import styled from '@emotion/styled';
-import debounce from 'lodash/debounce';
-import startCase from 'lodash/startCase';
-
-import {Button} from 'sentry/components/button';
-import Confirm from 'sentry/components/confirm';
-import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
-import {Item} from 'sentry/components/dropdownAutoComplete/types';
-import DropdownButton from 'sentry/components/dropdownButton';
-import EmptyMessage from 'sentry/components/emptyMessage';
-import {TeamBadge} from 'sentry/components/idBadge/teamBadge';
-import Link from 'sentry/components/links/link';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
-import RoleSelectControl from 'sentry/components/roleSelectControl';
-import {Tooltip} from 'sentry/components/tooltip';
-import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
-import {IconSubtract} from 'sentry/icons';
-import {t, tct} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import {Member, Organization, Team} from 'sentry/types';
-import {getEffectiveOrgRole} from 'sentry/utils/orgRole';
-import useTeams from 'sentry/utils/useTeams';
-import {
-  hasOrgRoleOverwrite,
-  RoleOverwritePanelAlert,
-} from 'sentry/views/settings/organizationTeams/roleOverwriteWarning';
-import {getButtonHelpText} from 'sentry/views/settings/organizationTeams/utils';
-
-type Props = {
-  /**
-   * Should button be disabled
-   */
-  disabled: boolean;
-  /**
-   * Used when showing Teams for a Member
-   * Prevent changes to a SCIM-provisioned member
-   */
-  enforceIdpProvisioned: boolean;
-  /**
-   * callback when teams are added
-   */
-  onAddTeam: (teamSlug: string) => void;
-  /**
-   * Callback when teams are removed
-   */
-  onRemoveTeam: (teamSlug: string) => void;
-  organization: Organization;
-  /**
-   * Message to display when the last team is removed
-   * if empty no confirm will be displayed.
-   */
-  confirmLastTeamRemoveMessage?: string;
-  /**
-   * Allow adding to teams with org role
-   * if the user is an org owner
-   */
-  isOrgOwner?: boolean;
-  /**
-   * Used to determine whether we should show a loading state while waiting for teams
-   */
-  loadingTeams?: boolean;
-  /**
-   * Optional menu header.
-   */
-  menuHeader?: React.ReactElement;
-  /**
-   * Used when showing Teams for a Member
-   */
-  onChangeTeamRole?: (teamSlug: string, teamRole: string) => void;
-  /**
-   * Used when showing Teams for a Member
-   */
-  selectedOrgRole?: Member['orgRole'];
-  /**
-   * Used when showing Teams for a Member
-   */
-  selectedTeamRoles?: Member['teamRoles'];
-  /**
-   * Used when showing Teams for a Project
-   */
-  selectedTeams?: Team[];
-};
-
-function TeamSelect({
-  disabled,
-  isOrgOwner,
-  loadingTeams,
-  enforceIdpProvisioned,
-  menuHeader,
-  confirmLastTeamRemoveMessage,
-  selectedOrgRole,
-  selectedTeamRoles,
-  selectedTeams,
-  organization,
-  onAddTeam,
-  onRemoveTeam,
-  onChangeTeamRole,
-}: Props) {
-  const {teams, onSearch, fetching} = useTeams();
-  const {orgRoleList, teamRoleList} = organization;
-
-  const slugsToFilter: string[] =
-    selectedTeams?.map(tm => tm.slug) || selectedTeamRoles?.map(tm => tm.teamSlug) || [];
-
-  // Determine if adding a team changes the minimum team-role
-  // Get org roles from team membership, if any
-  const orgRolesFromTeams = teams
-    .filter(team => slugsToFilter.includes(team.slug) && team.orgRole)
-    .map(team => team.orgRole as string);
-
-  if (selectedOrgRole) {
-    orgRolesFromTeams.push(selectedOrgRole);
-  }
-
-  // Sort them and to get the highest priority role
-  // Highest prio role may change minimum team role
-  const effectiveOrgRole = getEffectiveOrgRole(orgRolesFromTeams, orgRoleList)?.id;
-
-  const renderBody = () => {
-    const numTeams = selectedTeams?.length || selectedTeamRoles?.length;
-    if (numTeams === 0) {
-      return <EmptyMessage>{t('No Teams assigned')}</EmptyMessage>;
-    }
-
-    const confirmMessage =
-      numTeams === 1 && confirmLastTeamRemoveMessage
-        ? confirmLastTeamRemoveMessage
-        : null;
-
-    return (
-      <React.Fragment>
-        {organization.features.includes('team-roles') && effectiveOrgRole && (
-          <RoleOverwritePanelAlert
-            orgRole={effectiveOrgRole}
-            orgRoleList={orgRoleList}
-            teamRoleList={teamRoleList}
-          />
-        )}
-
-        {selectedTeams &&
-          selectedTeams.map(team => (
-            <ProjectTeamRow
-              key={team.slug}
-              disabled={disabled}
-              confirmMessage={confirmMessage}
-              organization={organization}
-              team={team}
-              onRemoveTeam={slug => onRemoveTeam(slug)}
-            />
-          ))}
-
-        {effectiveOrgRole &&
-          selectedTeamRoles &&
-          /**
-           * "Map + Find" operation is O(n * n), leaving it as it us because it is unlikely to cause performance issues because a Member is unlikely to be in 1000+ teams
-           */
-          selectedTeamRoles.map(r => {
-            const team = teams.find(tm => tm.slug === r.teamSlug);
-            if (!team) {
-              return (
-                <TeamPanelItem key={r.teamSlug}>
-                  {tct(`Cannot find #[slug]`, {slug: r.teamSlug})}
-                </TeamPanelItem>
-              );
-            }
-
-            return (
-              <MemberTeamRow
-                key={r.teamSlug}
-                disabled={disabled}
-                enforceIdpProvisioned={enforceIdpProvisioned}
-                confirmMessage={confirmMessage}
-                organization={organization}
-                team={team}
-                isOrgOwner={isOrgOwner ?? false}
-                selectedOrgRole={effectiveOrgRole}
-                selectedTeamRole={r.role}
-                onChangeTeamRole={onChangeTeamRole}
-                onRemoveTeam={slug => onRemoveTeam(slug)}
-              />
-            );
-          })}
-      </React.Fragment>
-    );
-  };
-
-  // Only show options that aren't selected in the dropdown
-  const options = teams
-    .filter(team => !slugsToFilter.some(slug => slug === team.slug))
-    .map((team, index) => {
-      const isIdpProvisioned = enforceIdpProvisioned && team.flags['idp:provisioned'];
-
-      return {
-        index,
-        value: team.slug,
-        searchKey: team.slug,
-        label: () => {
-          // TODO(team-roles): team admins can also manage membership
-          const isPermissionGroup = team.orgRole !== null && !isOrgOwner;
-          const buttonHelpText = getButtonHelpText(isIdpProvisioned, isPermissionGroup);
-
-          if (isIdpProvisioned || isPermissionGroup) {
-            return (
-              <Tooltip title={buttonHelpText}>
-                <DropdownTeamBadgeDisabled avatarSize={18} team={team} />
-              </Tooltip>
-            );
-          }
-
-          return <DropdownTeamBadge avatarSize={18} team={team} />;
-        },
-        disabled: disabled || isIdpProvisioned || (team.orgRole !== null && !isOrgOwner),
-      };
-    });
-
-  return (
-    <Panel>
-      <PanelHeader hasButtons>
-        {t('Team')}
-        <DropdownAutoComplete
-          items={options}
-          busyItemsStillVisible={fetching}
-          onChange={debounce<(e: React.ChangeEvent<HTMLInputElement>) => void>(
-            e => onSearch(e.target.value),
-            DEFAULT_DEBOUNCE_DURATION
-          )}
-          onSelect={(option: Item) => onAddTeam(option.value)}
-          emptyMessage={t('No teams')}
-          menuHeader={menuHeader}
-          disabled={disabled}
-          alignMenu="right"
-        >
-          {({isOpen}) => (
-            <DropdownButton
-              aria-label={t('Add Team')}
-              isOpen={isOpen}
-              size="xs"
-              disabled={disabled}
-            >
-              {t('Add Team')}
-            </DropdownButton>
-          )}
-        </DropdownAutoComplete>
-      </PanelHeader>
-
-      <PanelBody>{loadingTeams ? <LoadingIndicator /> : renderBody()}</PanelBody>
-    </Panel>
-  );
-}
-
-type TeamRowProps = {
-  confirmMessage: string | null;
-  disabled: boolean;
-  onRemoveTeam: Props['onRemoveTeam'];
-  organization: Organization;
-  team: Team;
-};
-
-type ProjectTeamRowProps = {} & TeamRowProps;
-
-function ProjectTeamRow({
-  organization,
-  team,
-  onRemoveTeam,
-  disabled,
-  confirmMessage,
-}: ProjectTeamRowProps) {
-  return (
-    <TeamPanelItem data-test-id="team-row-for-project">
-      <StyledLink to={`/settings/${organization.slug}/teams/${team.slug}/`}>
-        <TeamBadge team={team} />
-      </StyledLink>
-
-      <Confirm
-        message={confirmMessage}
-        bypass={!confirmMessage}
-        onConfirm={() => onRemoveTeam(team.slug)}
-        disabled={disabled}
-      >
-        <Button size="xs" icon={<IconSubtract isCircled size="xs" />} disabled={disabled}>
-          {t('Remove')}
-        </Button>
-      </Confirm>
-    </TeamPanelItem>
-  );
-}
-
-type MemberTeamRowProps = {
-  enforceIdpProvisioned: boolean;
-  isOrgOwner: boolean;
-  onChangeTeamRole: Props['onChangeTeamRole'];
-  selectedOrgRole: Member['orgRole'];
-  selectedTeamRole: Member['teamRoles'][0]['role'];
-} & TeamRowProps;
-
-function MemberTeamRow({
-  organization,
-  team,
-  selectedOrgRole,
-  selectedTeamRole,
-  onRemoveTeam,
-  onChangeTeamRole,
-  isOrgOwner,
-  disabled,
-  confirmMessage,
-  enforceIdpProvisioned,
-}: MemberTeamRowProps) {
-  const {teamRoleList, orgRoleList} = organization;
-  const isRoleOverwritten = hasOrgRoleOverwrite({
-    orgRole: selectedOrgRole,
-    orgRoleList,
-    teamRoleList,
-  });
-
-  const teamRoleObj = isRoleOverwritten
-    ? teamRoleList[1] // set as team admin
-    : teamRoleList.find(r => r.id === selectedTeamRole) || teamRoleList[0];
-
-  const orgRoleFromTeam = team.orgRole ? `${startCase(team.orgRole)} Team` : null;
-
-  const isIdpProvisioned = enforceIdpProvisioned && team.flags['idp:provisioned'];
-  const isPermissionGroup = team.orgRole !== null && !isOrgOwner;
-  const isRemoveDisabled = disabled || isIdpProvisioned || isPermissionGroup;
-
-  const buttonHelpText = getButtonHelpText(isIdpProvisioned, isPermissionGroup);
-
-  return (
-    <TeamPanelItem data-test-id="team-row-for-member">
-      <StyledLink to={`/settings/${organization.slug}/teams/${team.slug}/`}>
-        <TeamBadge team={team} />
-      </StyledLink>
-
-      <TeamOrgRole>{orgRoleFromTeam}</TeamOrgRole>
-
-      {organization.features.includes('team-roles') && onChangeTeamRole && (
-        <React.Fragment>
-          <StyledRoleSelectControl
-            disabled={disabled || isRoleOverwritten}
-            disableUnallowed={false}
-            size="xs"
-            roles={teamRoleList}
-            value={teamRoleObj?.id}
-            onChange={option => onChangeTeamRole(team.slug, option.value)}
-          />
-        </React.Fragment>
-      )}
-
-      <Confirm
-        message={confirmMessage}
-        bypass={!confirmMessage}
-        onConfirm={() => onRemoveTeam(team.slug)}
-        disabled={isRemoveDisabled}
-      >
-        <Button
-          size="xs"
-          icon={<IconSubtract isCircled size="xs" />}
-          disabled={isRemoveDisabled}
-          title={buttonHelpText}
-        >
-          {t('Remove')}
-        </Button>
-      </Confirm>
-    </TeamPanelItem>
-  );
-}
-
-const DropdownTeamBadge = styled(TeamBadge)`
-  font-weight: normal;
-  font-size: ${p => p.theme.fontSizeMedium};
-  text-transform: none;
-`;
-
-const DropdownTeamBadgeDisabled = styled(TeamBadge)`
-  font-weight: normal;
-  font-size: ${p => p.theme.fontSizeMedium};
-  text-transform: none;
-  filter: grayscale(1);
-`;
-
-const TeamPanelItem = styled(PanelItem)`
-  padding: ${space(2)};
-  align-items: center;
-  justify-content: space-between;
-`;
-
-const StyledLink = styled(Link)`
-  flex-grow: 4;
-`;
-
-const TeamOrgRole = styled('div')`
-  min-width: 90px;
-  flex-grow: 1;
-  display: flex;
-  justify-content: center;
-`;
-
-const StyledRoleSelectControl = styled(RoleSelectControl)`
-  min-width: 200px;
-  margin-right: ${space(2)};
-`;
-
-export default TeamSelect;

+ 212 - 0
static/app/views/settings/components/teamSelect/teamSelectForMember.tsx

@@ -0,0 +1,212 @@
+import React from 'react';
+import styled from '@emotion/styled';
+import startCase from 'lodash/startCase';
+
+import {Button} from 'sentry/components/button';
+import EmptyMessage from 'sentry/components/emptyMessage';
+import {TeamBadge} from 'sentry/components/idBadge/teamBadge';
+import Link from 'sentry/components/links/link';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
+import TeamRoleSelect from 'sentry/components/teamRoleSelect';
+import {IconSubtract} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Member, Organization, Team} from 'sentry/types';
+import {getEffectiveOrgRole} from 'sentry/utils/orgRole';
+import useTeams from 'sentry/utils/useTeams';
+import {RoleOverwritePanelAlert} from 'sentry/views/settings/organizationTeams/roleOverwriteWarning';
+import {getButtonHelpText} from 'sentry/views/settings/organizationTeams/utils';
+
+import {DropdownAddTeam, TeamSelectProps} from './utils';
+
+type Props = TeamSelectProps & {
+  /**
+   * Member that this component is acting upon
+   */
+  member: Member;
+  /**
+   * Used when showing Teams for a Member
+   */
+  onChangeTeamRole: (teamSlug: string, teamRole: string) => void;
+  /**
+   * Used when showing Teams for a Member
+   */
+  selectedOrgRole: Member['orgRole'];
+  /**
+   * Used when showing Teams for a Member
+   */
+  selectedTeamRoles: Member['teamRoles'];
+};
+
+function TeamSelect({
+  disabled,
+  loadingTeams,
+  member,
+  selectedOrgRole,
+  selectedTeamRoles,
+  organization,
+  onAddTeam,
+  onRemoveTeam,
+  onCreateTeam,
+  onChangeTeamRole,
+}: Props) {
+  const {teams, onSearch, fetching: isLoadingTeams} = useTeams();
+  const {orgRoleList, teamRoleList} = organization;
+
+  const selectedTeamSlugs = new Set(selectedTeamRoles.map(tm => tm.teamSlug));
+  const selectedTeams = teams.filter(tm => selectedTeamSlugs.has(tm.slug));
+
+  // Determine if adding a team changes the minimum team-role
+  // Get org-roles from team membership, if any
+  const groupOrgRoles = selectedTeams
+    .filter(team => team.orgRole)
+    .map(team => team.orgRole as string);
+  if (selectedOrgRole) {
+    groupOrgRoles.push(selectedOrgRole);
+  }
+
+  // Sort them and to get the highest priority role
+  // Highest priority role may change minimum team role
+  const effectiveOrgRole = getEffectiveOrgRole(groupOrgRoles, orgRoleList);
+
+  const renderBody = () => {
+    if (selectedTeams.length === 0) {
+      return <EmptyMessage>{t('No Teams assigned')}</EmptyMessage>;
+    }
+
+    return (
+      <React.Fragment>
+        {organization.features.includes('team-roles') && effectiveOrgRole && (
+          <RoleOverwritePanelAlert
+            orgRole={effectiveOrgRole?.id}
+            orgRoleList={orgRoleList}
+            teamRoleList={teamRoleList}
+          />
+        )}
+
+        {selectedTeams.map(team => (
+          <TeamRow
+            key={team.slug}
+            disabled={disabled}
+            organization={organization}
+            team={team}
+            member={{
+              ...member,
+              orgRolesFromTeams: [{role: effectiveOrgRole, teamSlug: ''}],
+              orgRole: selectedOrgRole,
+              teamRoles: selectedTeamRoles,
+            }}
+            onChangeTeamRole={onChangeTeamRole}
+            onRemoveTeam={slug => onRemoveTeam(slug)}
+          />
+        ))}
+      </React.Fragment>
+    );
+  };
+
+  return (
+    <Panel>
+      <PanelHeader hasButtons>
+        {t('Team')}
+        <DropdownAddTeam
+          disabled={disabled}
+          isLoadingTeams={isLoadingTeams}
+          isAddingTeamToMember
+          canCreateTeam={false}
+          onSearch={onSearch}
+          onSelect={onAddTeam}
+          onCreateTeam={onCreateTeam}
+          organization={organization}
+          selectedTeams={selectedTeams.map(tm => tm.slug)}
+          teams={teams}
+        />
+      </PanelHeader>
+
+      <PanelBody>{loadingTeams ? <LoadingIndicator /> : renderBody()}</PanelBody>
+    </Panel>
+  );
+}
+
+function TeamRow({
+  disabled,
+  organization,
+  team,
+  member,
+  onRemoveTeam,
+  onChangeTeamRole,
+}: {
+  disabled: boolean;
+  member: Member;
+  onChangeTeamRole: Props['onChangeTeamRole'];
+  onRemoveTeam: Props['onRemoveTeam'];
+  organization: Organization;
+  team: Team;
+}) {
+  const hasOrgAdmin = organization.access.includes('org:admin');
+  const isIdpProvisioned = team.flags['idp:provisioned'];
+  const isPermissionGroup = team.orgRole !== null && !hasOrgAdmin;
+  const isRemoveDisabled = disabled || isIdpProvisioned || isPermissionGroup;
+
+  const buttonHelpText = getButtonHelpText(isIdpProvisioned, isPermissionGroup);
+  const orgRoleFromTeam = team.orgRole ? `${startCase(team.orgRole)} Team` : null;
+
+  return (
+    <TeamPanelItem data-test-id="team-row-for-member">
+      <TeamPanelItemLeft>
+        <Link to={`/settings/${organization.slug}/teams/${team.slug}/`}>
+          <TeamBadge team={team} />
+        </Link>
+      </TeamPanelItemLeft>
+
+      <TeamOrgRole>{orgRoleFromTeam}</TeamOrgRole>
+
+      {organization.features.includes('team-roles') && (
+        <RoleSelectWrapper>
+          <TeamRoleSelect
+            disabled={disabled}
+            size="xs"
+            organization={organization}
+            team={team}
+            member={member}
+            onChangeTeamRole={newRole => onChangeTeamRole(team.slug, newRole)}
+          />
+        </RoleSelectWrapper>
+      )}
+
+      <Button
+        size="xs"
+        icon={<IconSubtract isCircled size="xs" />}
+        title={buttonHelpText}
+        disabled={isRemoveDisabled}
+        onClick={() => onRemoveTeam(team.slug)}
+      >
+        {t('Remove')}
+      </Button>
+    </TeamPanelItem>
+  );
+}
+
+const TeamPanelItem = styled(PanelItem)`
+  padding: ${space(2)};
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const TeamPanelItemLeft = styled('div')`
+  flex-grow: 4;
+`;
+
+const TeamOrgRole = styled('div')`
+  min-width: 90px;
+  flex-grow: 1;
+  display: flex;
+  justify-content: center;
+`;
+
+const RoleSelectWrapper = styled('div')`
+  min-width: 200px;
+  margin-right: ${space(2)};
+`;
+
+export default TeamSelect;

+ 143 - 0
static/app/views/settings/components/teamSelect/teamSelectForProject.tsx

@@ -0,0 +1,143 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import Confirm from 'sentry/components/confirm';
+import EmptyMessage from 'sentry/components/emptyMessage';
+import {TeamBadge} from 'sentry/components/idBadge/teamBadge';
+import Link from 'sentry/components/links/link';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
+import {IconSubtract} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Organization, Project, Team} from 'sentry/types';
+import useTeams from 'sentry/utils/useTeams';
+
+import {DropdownAddTeam, TeamSelectProps} from './utils';
+
+type Props = TeamSelectProps & {
+  canCreateTeam: boolean;
+  project: Project;
+  /**
+   * Used when showing Teams for a Project
+   */
+  selectedTeams: Team[];
+  /**
+   * Message to display when the last team is removed
+   * if empty no confirm will be displayed.
+   */
+  confirmLastTeamRemoveMessage?: string;
+};
+
+function TeamSelect({
+  disabled,
+  canCreateTeam,
+  confirmLastTeamRemoveMessage,
+  project,
+  selectedTeams,
+  organization,
+  onAddTeam,
+  onRemoveTeam,
+  onCreateTeam,
+}: Props) {
+  const renderBody = () => {
+    const numTeams = selectedTeams.length;
+    if (numTeams === 0) {
+      return <EmptyMessage>{t('No Teams assigned')}</EmptyMessage>;
+    }
+
+    const confirmMessage =
+      numTeams === 1 && confirmLastTeamRemoveMessage
+        ? confirmLastTeamRemoveMessage
+        : null;
+
+    return (
+      <React.Fragment>
+        {selectedTeams.map(team => (
+          <TeamRow
+            key={team.slug}
+            disabled={disabled}
+            confirmMessage={confirmMessage}
+            organization={organization}
+            team={team}
+            onRemoveTeam={slug => onRemoveTeam(slug)}
+          />
+        ))}
+      </React.Fragment>
+    );
+  };
+
+  const {teams, onSearch, fetching: isLoadingTeams} = useTeams();
+
+  return (
+    <Panel>
+      <PanelHeader hasButtons>
+        {t('Team')}
+
+        <DropdownAddTeam
+          disabled={disabled}
+          isLoadingTeams={isLoadingTeams}
+          isAddingTeamToProject
+          canCreateTeam={canCreateTeam}
+          onSearch={onSearch}
+          onSelect={onAddTeam}
+          onCreateTeam={onCreateTeam}
+          organization={organization}
+          selectedTeams={selectedTeams.map(tm => tm.slug)}
+          teams={teams}
+          project={project}
+        />
+      </PanelHeader>
+
+      <PanelBody>{isLoadingTeams ? <LoadingIndicator /> : renderBody()}</PanelBody>
+    </Panel>
+  );
+}
+
+function TeamRow({
+  organization,
+  team,
+  onRemoveTeam,
+  disabled,
+  confirmMessage,
+}: {
+  confirmMessage: string | null;
+  disabled: boolean;
+  onRemoveTeam: Props['onRemoveTeam'];
+  organization: Organization;
+  team: Team;
+}) {
+  return (
+    <TeamPanelItem data-test-id="team-row-for-project">
+      <TeamPanelItemLeft>
+        <Link to={`/settings/${organization.slug}/teams/${team.slug}/`}>
+          <TeamBadge team={team} />
+        </Link>
+      </TeamPanelItemLeft>
+
+      <Confirm
+        message={confirmMessage}
+        bypass={!confirmMessage}
+        onConfirm={() => onRemoveTeam(team.slug)}
+        disabled={disabled}
+      >
+        <Button size="xs" icon={<IconSubtract isCircled size="xs" />} disabled={disabled}>
+          {t('Remove')}
+        </Button>
+      </Confirm>
+    </TeamPanelItem>
+  );
+}
+
+const TeamPanelItem = styled(PanelItem)`
+  padding: ${space(2)};
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const TeamPanelItemLeft = styled('div')`
+  flex-grow: 4;
+`;
+
+export default TeamSelect;

+ 228 - 0
static/app/views/settings/components/teamSelect/utils.tsx

@@ -0,0 +1,228 @@
+import React from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+import debounce from 'lodash/debounce';
+
+import {openCreateTeamModal} from 'sentry/actionCreators/modal';
+import {hasEveryAccess} from 'sentry/components/acl/access';
+import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
+import {Item} from 'sentry/components/dropdownAutoComplete/types';
+import DropdownButton from 'sentry/components/dropdownButton';
+import {TeamBadge} from 'sentry/components/idBadge/teamBadge';
+import Link from 'sentry/components/links/link';
+import {Tooltip} from 'sentry/components/tooltip';
+import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Organization, Project, Team} from 'sentry/types';
+import {getButtonHelpText} from 'sentry/views/settings/organizationTeams/utils';
+
+export type TeamSelectProps = {
+  /**
+   * Should button be disabled
+   */
+  disabled: boolean;
+  /**
+   * callback when teams are added
+   */
+  onAddTeam: (teamSlug: string) => void;
+  /**
+   * Callback when teams are removed
+   */
+  onRemoveTeam: (teamSlug: string) => void;
+  organization: Organization;
+  /**
+   * Used to determine whether we should show a loading state while waiting for teams
+   */
+  loadingTeams?: boolean;
+  /**
+   * Callback when teams are created
+   */
+  onCreateTeam?: (team: Team) => void;
+};
+
+export function DropdownAddTeam({
+  disabled,
+  isLoadingTeams,
+  isAddingTeamToMember = false,
+  isAddingTeamToProject = false,
+  onSearch,
+  onSelect,
+  onCreateTeam,
+  organization,
+  selectedTeams,
+  teams,
+  project,
+}: {
+  disabled: boolean;
+  isLoadingTeams: boolean;
+  onSearch: (teamSlug: string) => void;
+  onSelect: (teamSlug: string) => void;
+  organization: Organization;
+  selectedTeams: string[];
+  teams: Team[];
+  canCreateTeam?: boolean;
+  isAddingTeamToMember?: boolean;
+  isAddingTeamToProject?: boolean;
+  onCreateTeam?: (team: Team) => void;
+  project?: Project;
+}) {
+  const dropdownItems = teams
+    .filter(team => !selectedTeams.some(slug => slug === team.slug))
+    .map((team, index) =>
+      renderDropdownOption({
+        isAddingTeamToMember,
+        isAddingTeamToProject,
+        organization,
+        team,
+        index,
+        disabled,
+      })
+    );
+
+  const onDropdownChange = debounce<(e: React.ChangeEvent<HTMLInputElement>) => void>(
+    e => onSearch(e.target.value),
+    DEFAULT_DEBOUNCE_DURATION
+  );
+
+  return (
+    <DropdownAutoComplete
+      items={dropdownItems}
+      busyItemsStillVisible={isLoadingTeams}
+      onChange={onDropdownChange}
+      onSelect={(option: Item) => onSelect(option.value)}
+      emptyMessage={t('No teams')}
+      menuHeader={renderDropdownHeader({
+        organization,
+        project,
+        onCreateTeam,
+      })}
+      disabled={disabled}
+      alignMenu="right"
+    >
+      {({isOpen}) => (
+        <DropdownButton
+          aria-label={t('Add Team')}
+          isOpen={isOpen}
+          size="xs"
+          disabled={disabled}
+        >
+          {t('Add Team')}
+        </DropdownButton>
+      )}
+    </DropdownAutoComplete>
+  );
+}
+
+function renderDropdownOption({
+  disabled,
+  index,
+  isAddingTeamToMember,
+  organization,
+  team,
+}: {
+  disabled: boolean;
+  index: number;
+  isAddingTeamToMember: boolean;
+  isAddingTeamToProject: boolean;
+  organization: Organization;
+  team: Team;
+}) {
+  const hasOrgAdmin = organization.access.includes('org:admin');
+  const isIdpProvisioned = isAddingTeamToMember && team.flags['idp:provisioned'];
+  const isPermissionGroup = isAddingTeamToMember && team.orgRole !== null && !hasOrgAdmin;
+  const buttonHelpText = getButtonHelpText(isIdpProvisioned, isPermissionGroup);
+
+  return {
+    index,
+    value: team.slug,
+    searchKey: team.slug,
+    label: () => {
+      if (isIdpProvisioned || isPermissionGroup) {
+        return (
+          <Tooltip title={buttonHelpText}>
+            <DropdownTeamBadgeDisabled avatarSize={18} team={team} />
+          </Tooltip>
+        );
+      }
+
+      return <DropdownTeamBadge avatarSize={18} team={team} />;
+    },
+    disabled: disabled || isIdpProvisioned || isPermissionGroup,
+  };
+}
+
+function renderDropdownHeader({
+  organization,
+  project,
+  onCreateTeam,
+}: {
+  organization: Organization;
+  onCreateTeam?: (team) => void;
+  project?: Project;
+}) {
+  const canCreateTeam = hasEveryAccess(['org:write'], {organization, project});
+
+  return (
+    <StyledTeamsLabel>
+      <span>{t('Teams')}</span>
+
+      <Tooltip
+        disabled={canCreateTeam}
+        title={t('You must be a Org Owner/Manager to create teams')}
+        position="top"
+      >
+        <StyledCreateTeamLink
+          to="#create-team"
+          disabled={!canCreateTeam}
+          onClick={(e: React.MouseEvent) => {
+            e.stopPropagation();
+            e.preventDefault();
+
+            openCreateTeamModal({
+              organization,
+              project,
+              onClose: onCreateTeam,
+            });
+          }}
+        >
+          {t('Create Team')}
+        </StyledCreateTeamLink>
+      </Tooltip>
+    </StyledTeamsLabel>
+  );
+}
+
+const DropdownTeamBadge = styled(TeamBadge)`
+  font-weight: normal;
+  font-size: ${p => p.theme.fontSizeMedium};
+  text-transform: none;
+`;
+
+const DropdownTeamBadgeDisabled = styled(TeamBadge)`
+  font-weight: normal;
+  font-size: ${p => p.theme.fontSizeMedium};
+  text-transform: none;
+  filter: grayscale(1);
+`;
+
+const StyledTeamsLabel = styled('div')`
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  font-size: 0.875em;
+  padding: ${space(0.5)} 0px;
+  text-transform: uppercase;
+`;
+
+const StyledCreateTeamLink = styled(Link)`
+  float: right;
+  text-transform: none;
+  ${p =>
+    p.disabled &&
+    css`
+      cursor: not-allowed;
+      color: ${p.theme.gray300};
+      opacity: 0.6;
+    `};
+`;

+ 1 - 1
static/app/views/settings/organizationMembers/organizationMemberDetail.spec.jsx

@@ -263,7 +263,7 @@ describe('OrganizationMemberDetail', function () {
       await userEvent.click(screen.getByText('#new-team'));
 
       // Assign as admin to new team
-      const teamRoleSelect = screen.getAllByText('Contributor')[1];
+      const teamRoleSelect = screen.getAllByText('Contributor')[0];
       await selectEvent.select(teamRoleSelect, ['Team Admin']);
 
       // Save Member

+ 31 - 21
static/app/views/settings/organizationMembers/organizationMemberDetail.tsx

@@ -33,7 +33,7 @@ import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import withOrganization from 'sentry/utils/withOrganization';
 import AsyncView from 'sentry/views/asyncView';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
-import TeamSelect from 'sentry/views/settings/components/teamSelect';
+import TeamSelectForMember from 'sentry/views/settings/components/teamSelect/teamSelectForMember';
 
 import OrganizationRoleSelect from './inviteMember/orgRoleSelect';
 
@@ -53,9 +53,10 @@ type Props = {
 } & RouteComponentProps<RouteParams, {}>;
 
 type State = {
+  groupOrgRoles: Member['orgRolesFromTeams']; // Form state
   member: Member | null;
-  orgRole: Member['orgRole'];
-  teamRoles: Member['teamRoles'];
+  orgRole: Member['orgRole']; // Form state
+  teamRoles: Member['teamRoles']; // Form state
 } & AsyncView['state'];
 
 const DisabledMemberTooltip = HookOrDefault({
@@ -72,6 +73,7 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
   getDefaultState(): State {
     return {
       ...super.getDefaultState(),
+      groupOrgRoles: [],
       member: null,
       orgRole: '',
       teamRoles: [],
@@ -87,8 +89,12 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
 
   onRequestSuccess({data, stateKey}: {data: Member; stateKey: string}) {
     if (stateKey === 'member') {
-      const {orgRole, teamRoles} = data;
-      this.setState({orgRole, teamRoles});
+      const {orgRole, teamRoles, orgRolesFromTeams} = data;
+      this.setState({
+        orgRole,
+        teamRoles,
+        groupOrgRoles: orgRolesFromTeams,
+      });
     }
   }
 
@@ -105,7 +111,12 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
         memberId: params.memberId,
         data: {orgRole, teamRoles} as any,
       });
-      this.setState({member: updatedMember, busy: false});
+      this.setState({
+        member: updatedMember,
+        orgRole: updatedMember.orgRole,
+        teamRoles: updatedMember.teamRoles,
+        busy: false,
+      });
       addSuccessMessage(t('Saved'));
     } catch (resp) {
       const errorMessage =
@@ -239,7 +250,7 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
       return false;
     }
 
-    if (orgRole !== member.orgRole || !isEqual(teamRoles, member?.teamRoles)) {
+    if (orgRole !== member.orgRole || !isEqual(teamRoles, member.teamRoles)) {
       return true;
     }
 
@@ -273,8 +284,6 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
 
     const {access, features, orgRoleList} = organization;
     const canEdit = access.includes('org:write') && !this.memberDeactivated;
-    // org:admin is a unique scope that only org owners have
-    const isOrgOwner = access.includes('org:admin');
     const hasTeamRoles = features.includes('team-roles');
 
     const {email, expired, pending, invite_link: inviteLink} = member;
@@ -401,18 +410,19 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
 
         <Teams slugs={member.teams}>
           {({initiallyLoaded}) => (
-            <TeamSelect
-              enforceIdpProvisioned
-              disabled={!canEdit}
-              isOrgOwner={isOrgOwner}
-              organization={organization}
-              selectedOrgRole={orgRole}
-              selectedTeamRoles={teamRoles}
-              onChangeTeamRole={this.onChangeTeamRole}
-              onAddTeam={this.onAddTeam}
-              onRemoveTeam={this.onRemoveTeam}
-              loadingTeams={!initiallyLoaded}
-            />
+            <Fragment>
+              <TeamSelectForMember
+                disabled={!canEdit}
+                organization={organization}
+                member={member}
+                selectedOrgRole={orgRole}
+                selectedTeamRoles={teamRoles}
+                onChangeTeamRole={this.onChangeTeamRole}
+                onAddTeam={this.onAddTeam}
+                onRemoveTeam={this.onRemoveTeam}
+                loadingTeams={!initiallyLoaded}
+              />
+            </Fragment>
           )}
         </Teams>
 

+ 2 - 2
static/app/views/settings/organizationTeams/teamMembers.spec.jsx

@@ -288,8 +288,8 @@ describe('TeamMembers', function () {
     );
 
     // Can only remove self
-    expect(screen.getByRole('button', {name: 'Remove'})).toBeInTheDocument();
-    await userEvent.click(screen.getByRole('button', {name: 'Remove'}));
+    expect(screen.getByRole('button', {name: 'Leave'})).toBeInTheDocument();
+    await userEvent.click(screen.getByRole('button', {name: 'Leave'}));
     expect(deleteMock).toHaveBeenCalled();
   });
 

+ 42 - 13
static/app/views/settings/organizationTeams/teamMembers.tsx

@@ -10,6 +10,7 @@ import {
 } from 'sentry/actionCreators/modal';
 import {joinTeam, leaveTeam} from 'sentry/actionCreators/teams';
 import {Client} from 'sentry/api';
+import {hasEveryAccess} from 'sentry/components/acl/access';
 import UserAvatar from 'sentry/components/avatar/userAvatar';
 import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
 import {Item} from 'sentry/components/dropdownAutoComplete/types';
@@ -28,7 +29,11 @@ import withApi from 'sentry/utils/withApi';
 import withConfig from 'sentry/utils/withConfig';
 import withOrganization from 'sentry/utils/withOrganization';
 import AsyncView from 'sentry/views/asyncView';
+import TextBlock from 'sentry/views/settings/components/text/textBlock';
 import TeamMembersRow from 'sentry/views/settings/organizationTeams/teamMembersRow';
+import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
+
+import {getButtonHelpText} from './utils';
 
 type RouteParams = {
   teamId: string;
@@ -208,7 +213,7 @@ class TeamMembers extends AsyncView<Props, State> {
     this.debouncedFetchMembersRequest(e.target.value);
   };
 
-  renderDropdown(hasWriteAccess: boolean, isOrgOwner: boolean) {
+  renderDropdown(isTeamAdmin: boolean) {
     const {organization, params, team} = this.props;
     const {orgMembers} = this.state;
     const existingMembers = new Set(this.state.teamMembers.map(member => member.id));
@@ -216,10 +221,9 @@ class TeamMembers extends AsyncView<Props, State> {
     // members can add other members to a team if the `Open Membership` setting is enabled
     // otherwise, `org:write` or `team:admin` permissions are required
     const hasOpenMembership = !!organization?.openMembership;
-    const canAddMembers = hasOpenMembership || hasWriteAccess;
+    const canAddMembers = hasOpenMembership || isTeamAdmin;
 
-    const isDropdownDisabled =
-      team.flags['idp:provisioned'] || (team.orgRole !== null && !isOrgOwner);
+    const isDropdownDisabled = team.flags['idp:provisioned'];
 
     const items = (orgMembers || [])
       .filter(m => !existingMembers.has(m.id))
@@ -282,6 +286,24 @@ class TeamMembers extends AsyncView<Props, State> {
     );
   }
 
+  renderPageTextBlock() {
+    const {organization, team} = this.props;
+    const {openMembership} = organization;
+    const isIdpProvisioned = team.flags['idp:provisioned'];
+
+    if (isIdpProvisioned) {
+      return getButtonHelpText(isIdpProvisioned);
+    }
+
+    return openMembership
+      ? t(
+          '"Open Membership" is enabled for the organization. Anyone can add members for this team.'
+        )
+      : t(
+          '"Open Membership" is disabled for the organization. Org Owner/Manager/Admin, or Team Admins can add members for this team.'
+        );
+  }
+
   render() {
     if (this.state.loading) {
       return <LoadingIndicator />;
@@ -293,35 +315,42 @@ class TeamMembers extends AsyncView<Props, State> {
 
     const {organization, config, team} = this.props;
     const {teamMembersPageLinks} = this.state;
-    const {access} = organization;
-    const hasWriteAccess = access.includes('org:write') || access.includes('team:admin');
+    const {access, openMembership} = organization;
+
+    const hasOrgWriteAccess = hasEveryAccess(['org:write'], {organization, team});
+    const hasTeamAdminAccess = hasEveryAccess(['team:admin'], {organization, team});
+    const isTeamAdmin = hasOrgWriteAccess || hasTeamAdminAccess;
 
-    // TODO(team-roles): team admins can also manage membership
     // org:admin is a unique scope that only org owners have
     const isOrgOwner = access.includes('org:admin');
 
     return (
       <Fragment>
+        <TextBlock>{this.renderPageTextBlock()}</TextBlock>
+
+        <PermissionAlert
+          access={openMembership ? ['org:read'] : ['team:write']}
+          team={team}
+        />
+
         <Panel>
           <PanelHeader hasButtons>
             <div>{t('Members')}</div>
-            <div style={{textTransform: 'none'}}>
-              {this.renderDropdown(hasWriteAccess, isOrgOwner)}
-            </div>
+            <div style={{textTransform: 'none'}}>{this.renderDropdown(isTeamAdmin)}</div>
           </PanelHeader>
           {this.state.teamMembers.length ? (
             this.state.teamMembers.map(member => {
               return (
                 <TeamMembersRow
                   key={member.id}
-                  hasWriteAccess={hasWriteAccess}
+                  hasWriteAccess={isTeamAdmin}
                   isOrgOwner={isOrgOwner}
+                  organization={organization}
                   team={team}
                   member={member}
-                  organization={organization}
+                  user={config.user}
                   removeMember={this.removeTeamMember}
                   updateMemberRole={this.updateTeamMemberRole}
-                  user={config.user}
                 />
               );
             })

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