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 {t('No Teams assigned')}; } const confirmMessage = numTeams === 1 && confirmLastTeamRemoveMessage ? confirmLastTeamRemoveMessage : null; return ( {organization.features.includes('team-roles') && effectiveOrgRole && ( )} {selectedTeams && selectedTeams.map(team => ( 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 ( {tct(`Cannot find #[slug]`, {slug: r.teamSlug})} ); } return ( onRemoveTeam(slug)} /> ); })} ); }; // 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 ( ); } return ; }, disabled: disabled || isIdpProvisioned || (team.orgRole !== null && !isOrgOwner), }; }); return ( {t('Team')} ) => 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}) => ( {t('Add Team')} )} {loadingTeams ? : renderBody()} ); } type TeamRowProps = { confirmMessage: string | null; disabled: boolean; onRemoveTeam: Props['onRemoveTeam']; organization: Organization; team: Team; }; type ProjectTeamRowProps = {} & TeamRowProps; const ProjectTeamRow = ({ organization, team, onRemoveTeam, disabled, confirmMessage, }: ProjectTeamRowProps) => ( onRemoveTeam(team.slug)} disabled={disabled} > ); type MemberTeamRowProps = { enforceIdpProvisioned: boolean; isOrgOwner: boolean; onChangeTeamRole: Props['onChangeTeamRole']; selectedOrgRole: Member['orgRole']; selectedTeamRole: Member['teamRoles'][0]['role']; } & TeamRowProps; const 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 ( {orgRoleFromTeam} {organization.features.includes('team-roles') && onChangeTeamRole && ( onChangeTeamRole(team.slug, option.value)} /> )} onRemoveTeam(team.slug)} disabled={isRemoveDisabled} > ); }; 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;