teamSelect.tsx 12 KB

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