teamSelect.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import React from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import {Button} from 'sentry/components/button';
  5. import Confirm from 'sentry/components/confirm';
  6. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  7. import {Item} from 'sentry/components/dropdownAutoComplete/types';
  8. import DropdownButton from 'sentry/components/dropdownButton';
  9. import EmptyMessage from 'sentry/components/emptyMessage';
  10. import {TeamBadge} from 'sentry/components/idBadge/teamBadge';
  11. import Link from 'sentry/components/links/link';
  12. import LoadingIndicator from 'sentry/components/loadingIndicator';
  13. import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
  14. import RoleSelectControl from 'sentry/components/roleSelectControl';
  15. import {Tooltip} from 'sentry/components/tooltip';
  16. import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
  17. import {IconSubtract} from 'sentry/icons';
  18. import {t, tct} from 'sentry/locale';
  19. import {space} from 'sentry/styles/space';
  20. import {Member, Organization, Team} from 'sentry/types';
  21. import {getEffectiveOrgRole} from 'sentry/utils/orgRole';
  22. import useTeams from 'sentry/utils/useTeams';
  23. import {
  24. hasOrgRoleOverwrite,
  25. RoleOverwritePanelAlert,
  26. } from 'sentry/views/settings/organizationTeams/roleOverwriteWarning';
  27. type Props = {
  28. /**
  29. * Should button be disabled
  30. */
  31. disabled: boolean;
  32. /**
  33. * Used when showing Teams for a Member
  34. * Prevent changes to a SCIM-provisioned member
  35. */
  36. enforceIdpProvisioned: boolean;
  37. /**
  38. * callback when teams are added
  39. */
  40. onAddTeam: (teamSlug: string) => void;
  41. /**
  42. * Callback when teams are removed
  43. */
  44. onRemoveTeam: (teamSlug: string) => void;
  45. organization: Organization;
  46. /**
  47. * Message to display when the last team is removed
  48. * if empty no confirm will be displayed.
  49. */
  50. confirmLastTeamRemoveMessage?: string;
  51. /**
  52. * Used to determine whether we should show a loading state while waiting for teams
  53. */
  54. loadingTeams?: boolean;
  55. /**
  56. * Optional menu header.
  57. */
  58. menuHeader?: React.ReactElement;
  59. /**
  60. * Used when showing Teams for a Member
  61. */
  62. onChangeTeamRole?: (teamSlug: string, teamRole: string) => void;
  63. /**
  64. * Used when showing Teams for a Member
  65. */
  66. selectedOrgRole?: Member['orgRole'];
  67. /**
  68. * Used when showing Teams for a Member
  69. */
  70. selectedTeamRoles?: Member['teamRoles'];
  71. /**
  72. * Used when showing Teams for a Project
  73. */
  74. selectedTeams?: Team[];
  75. };
  76. function TeamSelect({
  77. disabled,
  78. loadingTeams,
  79. enforceIdpProvisioned,
  80. menuHeader,
  81. confirmLastTeamRemoveMessage,
  82. selectedOrgRole,
  83. selectedTeamRoles,
  84. selectedTeams,
  85. organization,
  86. onAddTeam,
  87. onRemoveTeam,
  88. onChangeTeamRole,
  89. }: Props) {
  90. const {teams, onSearch, fetching} = useTeams();
  91. const {orgRoleList, teamRoleList} = organization;
  92. const slugsToFilter: string[] =
  93. selectedTeams?.map(tm => tm.slug) || selectedTeamRoles?.map(tm => tm.teamSlug) || [];
  94. // Determine if adding a team changes the minimum team-role
  95. // Get org roles from team membership, if any
  96. const orgRolesFromTeams = teams
  97. .filter(team => slugsToFilter.includes(team.slug) && team.orgRole)
  98. .map(team => team.orgRole as string);
  99. if (selectedOrgRole) {
  100. orgRolesFromTeams.push(selectedOrgRole);
  101. }
  102. // Sort them and to get the highest priority role
  103. // Highest prio role may change minimum team role
  104. const effectiveOrgRole = getEffectiveOrgRole(orgRolesFromTeams, orgRoleList)?.id;
  105. const renderBody = () => {
  106. const numTeams = selectedTeams?.length || selectedTeamRoles?.length;
  107. if (numTeams === 0) {
  108. return <EmptyMessage>{t('No Teams assigned')}</EmptyMessage>;
  109. }
  110. const confirmMessage =
  111. numTeams === 1 && confirmLastTeamRemoveMessage
  112. ? confirmLastTeamRemoveMessage
  113. : null;
  114. return (
  115. <React.Fragment>
  116. {organization.features.includes('team-roles') && effectiveOrgRole && (
  117. <RoleOverwritePanelAlert
  118. orgRole={effectiveOrgRole}
  119. orgRoleList={orgRoleList}
  120. teamRoleList={teamRoleList}
  121. />
  122. )}
  123. {selectedTeams &&
  124. selectedTeams.map(team => (
  125. <ProjectTeamRow
  126. key={team.slug}
  127. disabled={disabled}
  128. confirmMessage={confirmMessage}
  129. organization={organization}
  130. team={team}
  131. onRemoveTeam={slug => onRemoveTeam(slug)}
  132. />
  133. ))}
  134. {effectiveOrgRole &&
  135. selectedTeamRoles &&
  136. /**
  137. * "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
  138. */
  139. selectedTeamRoles.map(r => {
  140. const team = teams.find(tm => tm.slug === r.teamSlug);
  141. if (!team) {
  142. return (
  143. <TeamPanelItem key={r.teamSlug}>
  144. {tct(`Cannot find #[slug]`, {slug: r.teamSlug})}
  145. </TeamPanelItem>
  146. );
  147. }
  148. return (
  149. <MemberTeamRow
  150. key={r.teamSlug}
  151. disabled={disabled}
  152. enforceIdpProvisioned={enforceIdpProvisioned}
  153. confirmMessage={confirmMessage}
  154. organization={organization}
  155. team={team}
  156. selectedOrgRole={effectiveOrgRole}
  157. selectedTeamRole={r.role}
  158. onChangeTeamRole={onChangeTeamRole}
  159. onRemoveTeam={slug => onRemoveTeam(slug)}
  160. />
  161. );
  162. })}
  163. </React.Fragment>
  164. );
  165. };
  166. // Only show options that aren't selected in the dropdown
  167. const options = teams
  168. .filter(team => !slugsToFilter.some(slug => slug === team.slug))
  169. .map((team, index) => ({
  170. index,
  171. value: team.slug,
  172. searchKey: team.slug,
  173. label: () => {
  174. if (enforceIdpProvisioned && team.flags['idp:provisioned']) {
  175. return (
  176. <Tooltip
  177. title={t(
  178. "Membership to this team is managed through your organization's identity provider."
  179. )}
  180. >
  181. <DropdownTeamBadgeDisabled avatarSize={18} team={team} />
  182. </Tooltip>
  183. );
  184. }
  185. return <DropdownTeamBadge avatarSize={18} team={team} />;
  186. },
  187. disabled: enforceIdpProvisioned && team.flags['idp:provisioned'],
  188. }));
  189. return (
  190. <Panel>
  191. <PanelHeader hasButtons>
  192. {t('Team')}
  193. <DropdownAutoComplete
  194. items={options}
  195. busyItemsStillVisible={fetching}
  196. onChange={debounce<(e: React.ChangeEvent<HTMLInputElement>) => void>(
  197. e => onSearch(e.target.value),
  198. DEFAULT_DEBOUNCE_DURATION
  199. )}
  200. onSelect={(option: Item) => onAddTeam(option.value)}
  201. emptyMessage={t('No teams')}
  202. menuHeader={menuHeader}
  203. disabled={disabled}
  204. alignMenu="right"
  205. >
  206. {({isOpen}) => (
  207. <DropdownButton
  208. aria-label={t('Add Team')}
  209. isOpen={isOpen}
  210. size="xs"
  211. disabled={disabled}
  212. >
  213. {t('Add Team')}
  214. </DropdownButton>
  215. )}
  216. </DropdownAutoComplete>
  217. </PanelHeader>
  218. <PanelBody>{loadingTeams ? <LoadingIndicator /> : renderBody()}</PanelBody>
  219. </Panel>
  220. );
  221. }
  222. type TeamRowProps = {
  223. confirmMessage: string | null;
  224. disabled: boolean;
  225. onRemoveTeam: Props['onRemoveTeam'];
  226. organization: Organization;
  227. team: Team;
  228. };
  229. type ProjectTeamRowProps = {} & TeamRowProps;
  230. const ProjectTeamRow = ({
  231. organization,
  232. team,
  233. onRemoveTeam,
  234. disabled,
  235. confirmMessage,
  236. }: ProjectTeamRowProps) => (
  237. <TeamPanelItem data-test-id="team-row-for-project">
  238. <StyledLink to={`/settings/${organization.slug}/teams/${team.slug}/`}>
  239. <TeamBadge team={team} />
  240. </StyledLink>
  241. <Confirm
  242. message={confirmMessage}
  243. bypass={!confirmMessage}
  244. onConfirm={() => onRemoveTeam(team.slug)}
  245. disabled={disabled}
  246. >
  247. <Button size="xs" icon={<IconSubtract isCircled size="xs" />} disabled={disabled}>
  248. {t('Remove')}
  249. </Button>
  250. </Confirm>
  251. </TeamPanelItem>
  252. );
  253. type MemberTeamRowProps = {
  254. enforceIdpProvisioned: boolean;
  255. onChangeTeamRole: Props['onChangeTeamRole'];
  256. selectedOrgRole: Member['orgRole'];
  257. selectedTeamRole: Member['teamRoles'][0]['role'];
  258. } & TeamRowProps;
  259. const MemberTeamRow = ({
  260. organization,
  261. team,
  262. selectedOrgRole,
  263. selectedTeamRole,
  264. onRemoveTeam,
  265. onChangeTeamRole,
  266. disabled,
  267. confirmMessage,
  268. enforceIdpProvisioned,
  269. }: MemberTeamRowProps) => {
  270. const {teamRoleList, orgRoleList} = organization;
  271. const isRoleOverwritten = hasOrgRoleOverwrite({
  272. orgRole: selectedOrgRole,
  273. orgRoleList,
  274. teamRoleList,
  275. });
  276. const teamRoleObj = isRoleOverwritten
  277. ? teamRoleList[1] // set as team admin
  278. : teamRoleList.find(r => r.id === selectedTeamRole) || teamRoleList[0];
  279. return (
  280. <TeamPanelItem data-test-id="team-row-for-member">
  281. <StyledLink to={`/settings/${organization.slug}/teams/${team.slug}/`}>
  282. <TeamBadge team={team} />
  283. </StyledLink>
  284. {organization.features.includes('team-roles') && onChangeTeamRole && (
  285. <React.Fragment>
  286. <StyledRoleSelectControl
  287. disabled={disabled || isRoleOverwritten}
  288. disableUnallowed={false}
  289. size="xs"
  290. roles={teamRoleList}
  291. value={teamRoleObj?.id}
  292. onChange={option => onChangeTeamRole(team.slug, option.value)}
  293. />
  294. </React.Fragment>
  295. )}
  296. <Confirm
  297. message={confirmMessage}
  298. bypass={!confirmMessage}
  299. onConfirm={() => onRemoveTeam(team.slug)}
  300. disabled={disabled || (enforceIdpProvisioned && team.flags['idp:provisioned'])}
  301. >
  302. <Button
  303. size="xs"
  304. icon={<IconSubtract isCircled size="xs" />}
  305. disabled={disabled || (enforceIdpProvisioned && team.flags['idp:provisioned'])}
  306. title={
  307. enforceIdpProvisioned && team.flags['idp:provisioned']
  308. ? t(
  309. "Membership to this team is managed through your organization's identity provider."
  310. )
  311. : undefined
  312. }
  313. >
  314. {t('Remove')}
  315. </Button>
  316. </Confirm>
  317. </TeamPanelItem>
  318. );
  319. };
  320. const DropdownTeamBadge = styled(TeamBadge)`
  321. font-weight: normal;
  322. font-size: ${p => p.theme.fontSizeMedium};
  323. text-transform: none;
  324. `;
  325. const DropdownTeamBadgeDisabled = styled(TeamBadge)`
  326. font-weight: normal;
  327. font-size: ${p => p.theme.fontSizeMedium};
  328. text-transform: none;
  329. filter: grayscale(1);
  330. `;
  331. const TeamPanelItem = styled(PanelItem)`
  332. padding: ${space(2)};
  333. align-items: center;
  334. justify-content: space-between;
  335. `;
  336. const StyledLink = styled(Link)`
  337. flex-grow: 1;
  338. `;
  339. const StyledRoleSelectControl = styled(RoleSelectControl)`
  340. min-width: 200px;
  341. margin-right: ${space(2)};
  342. `;
  343. export default TeamSelect;