123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402 |
- 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;
- const ProjectTeamRow = ({
- organization,
- team,
- onRemoveTeam,
- disabled,
- confirmMessage,
- }: ProjectTeamRowProps) => (
- <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;
- 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 (
- <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;
|