teamSelect.tsx 10 KB

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