teamMembers.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import {Fragment} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import debounce from 'lodash/debounce';
  5. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import {
  7. openInviteMembersModal,
  8. openTeamAccessRequestModal,
  9. } from 'sentry/actionCreators/modal';
  10. import {joinTeam, leaveTeam} from 'sentry/actionCreators/teams';
  11. import {Client} from 'sentry/api';
  12. import UserAvatar from 'sentry/components/avatar/userAvatar';
  13. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  14. import {Item} from 'sentry/components/dropdownAutoComplete/types';
  15. import DropdownButton from 'sentry/components/dropdownButton';
  16. import EmptyMessage from 'sentry/components/emptyMessage';
  17. import Link from 'sentry/components/links/link';
  18. import LoadingError from 'sentry/components/loadingError';
  19. import LoadingIndicator from 'sentry/components/loadingIndicator';
  20. import Pagination from 'sentry/components/pagination';
  21. import {Panel, PanelHeader} from 'sentry/components/panels';
  22. import {IconUser} from 'sentry/icons';
  23. import {t} from 'sentry/locale';
  24. import {space} from 'sentry/styles/space';
  25. import {Config, Member, Organization, Team, TeamMember} from 'sentry/types';
  26. import withApi from 'sentry/utils/withApi';
  27. import withConfig from 'sentry/utils/withConfig';
  28. import withOrganization from 'sentry/utils/withOrganization';
  29. import AsyncView from 'sentry/views/asyncView';
  30. import TeamMembersRow from 'sentry/views/settings/organizationTeams/teamMembersRow';
  31. type RouteParams = {
  32. teamId: string;
  33. };
  34. type Props = {
  35. api: Client;
  36. config: Config;
  37. organization: Organization;
  38. team: Team;
  39. } & RouteComponentProps<RouteParams, {}>;
  40. type State = {
  41. dropdownBusy: boolean;
  42. error: boolean;
  43. loading: boolean;
  44. orgMembers: Member[];
  45. teamMembers: TeamMember[];
  46. } & AsyncView['state'];
  47. class TeamMembers extends AsyncView<Props, State> {
  48. getDefaultState() {
  49. return {
  50. ...super.getDefaultState(),
  51. loading: true,
  52. error: false,
  53. dropdownBusy: false,
  54. teamMembers: [],
  55. orgMembers: [],
  56. };
  57. }
  58. componentDidMount() {
  59. // Initialize "add member" dropdown with data
  60. this.fetchMembersRequest('');
  61. }
  62. debouncedFetchMembersRequest = debounce(
  63. (query: string) =>
  64. this.setState({dropdownBusy: true}, () => this.fetchMembersRequest(query)),
  65. 200
  66. );
  67. fetchMembersRequest = async (query: string) => {
  68. const {organization, api} = this.props;
  69. try {
  70. const data = await api.requestPromise(
  71. `/organizations/${organization.slug}/members/`,
  72. {
  73. query: {query},
  74. }
  75. );
  76. this.setState({
  77. orgMembers: data,
  78. dropdownBusy: false,
  79. });
  80. } catch (_err) {
  81. addErrorMessage(t('Unable to load organization members.'), {
  82. duration: 2000,
  83. });
  84. this.setState({
  85. dropdownBusy: false,
  86. });
  87. }
  88. };
  89. getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
  90. const {organization, params} = this.props;
  91. return [
  92. [
  93. 'teamMembers',
  94. `/teams/${organization.slug}/${params.teamId}/members/`,
  95. {},
  96. {paginate: true},
  97. ],
  98. ];
  99. }
  100. addTeamMember = (selection: Item) => {
  101. const {organization, params} = this.props;
  102. const {orgMembers, teamMembers} = this.state;
  103. this.setState({loading: true});
  104. // Reset members list after adding member to team
  105. this.debouncedFetchMembersRequest('');
  106. joinTeam(
  107. this.props.api,
  108. {
  109. orgId: organization.slug,
  110. teamId: params.teamId,
  111. memberId: selection.value,
  112. },
  113. {
  114. success: () => {
  115. const orgMember = orgMembers.find(member => member.id === selection.value);
  116. if (orgMember === undefined) {
  117. return;
  118. }
  119. this.setState({
  120. loading: false,
  121. error: false,
  122. teamMembers: teamMembers.concat([orgMember as TeamMember]),
  123. });
  124. addSuccessMessage(t('Successfully added member to team.'));
  125. },
  126. error: () => {
  127. this.setState({loading: false});
  128. addErrorMessage(t('Unable to add team member.'));
  129. },
  130. }
  131. );
  132. };
  133. removeTeamMember = (member: Member) => {
  134. const {organization, params} = this.props;
  135. const {teamMembers} = this.state;
  136. leaveTeam(
  137. this.props.api,
  138. {
  139. orgId: organization.slug,
  140. teamId: params.teamId,
  141. memberId: member.id,
  142. },
  143. {
  144. success: () => {
  145. this.setState({
  146. teamMembers: teamMembers.filter(m => m.id !== member.id),
  147. });
  148. addSuccessMessage(t('Successfully removed member from team.'));
  149. },
  150. error: () =>
  151. addErrorMessage(
  152. t('There was an error while trying to remove a member from the team.')
  153. ),
  154. }
  155. );
  156. };
  157. updateTeamMemberRole = (member: Member, newRole: string) => {
  158. const {organization} = this.props;
  159. const {teamId} = this.props.params;
  160. const endpoint = `/organizations/${organization.slug}/members/${member.id}/teams/${teamId}/`;
  161. this.props.api.request(endpoint, {
  162. method: 'PUT',
  163. data: {teamRole: newRole},
  164. success: data => {
  165. const teamMembers: any = [...this.state.teamMembers];
  166. const i = teamMembers.findIndex(m => m.id === member.id);
  167. teamMembers[i] = {
  168. ...member,
  169. teamRole: data.teamRole,
  170. };
  171. this.setState({teamMembers});
  172. addSuccessMessage(t('Successfully changed role for team member.'));
  173. },
  174. error: () => {
  175. addErrorMessage(
  176. t('There was an error while trying to change the roles for a team member.')
  177. );
  178. },
  179. });
  180. };
  181. /**
  182. * We perform an API request to support orgs with > 100 members (since that's the max API returns)
  183. *
  184. * @param {Event} e React Event when member filter input changes
  185. */
  186. handleMemberFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  187. this.setState({dropdownBusy: true});
  188. this.debouncedFetchMembersRequest(e.target.value);
  189. };
  190. renderDropdown(hasWriteAccess: boolean, isOrgOwner: boolean) {
  191. const {organization, params, team} = this.props;
  192. const {orgMembers} = this.state;
  193. const existingMembers = new Set(this.state.teamMembers.map(member => member.id));
  194. // members can add other members to a team if the `Open Membership` setting is enabled
  195. // otherwise, `org:write` or `team:admin` permissions are required
  196. const hasOpenMembership = !!organization?.openMembership;
  197. const canAddMembers = hasOpenMembership || hasWriteAccess;
  198. const isDropdownDisabled =
  199. team.flags['idp:provisioned'] || (team.orgRole !== null && !isOrgOwner);
  200. const items = (orgMembers || [])
  201. .filter(m => !existingMembers.has(m.id))
  202. .map(m => ({
  203. searchKey: `${m.name} ${m.email}`,
  204. value: m.id,
  205. label: (
  206. <StyledUserListElement>
  207. <StyledAvatar user={m} size={24} className="avatar" />
  208. <StyledNameOrEmail>{m.name || m.email}</StyledNameOrEmail>
  209. </StyledUserListElement>
  210. ),
  211. }));
  212. const menuHeader = (
  213. <StyledMembersLabel>
  214. {t('Members')}
  215. <StyledCreateMemberLink
  216. to=""
  217. onClick={() => openInviteMembersModal({source: 'teams'})}
  218. data-test-id="invite-member"
  219. >
  220. {t('Invite Member')}
  221. </StyledCreateMemberLink>
  222. </StyledMembersLabel>
  223. );
  224. return (
  225. <DropdownAutoComplete
  226. items={items}
  227. alignMenu="right"
  228. onSelect={
  229. canAddMembers
  230. ? this.addTeamMember
  231. : selection =>
  232. openTeamAccessRequestModal({
  233. teamId: params.teamId,
  234. orgId: organization.slug,
  235. memberId: selection.value,
  236. })
  237. }
  238. menuHeader={menuHeader}
  239. emptyMessage={t('No members')}
  240. onChange={this.handleMemberFilterChange}
  241. busy={this.state.dropdownBusy}
  242. onClose={() => this.debouncedFetchMembersRequest('')}
  243. disabled={isDropdownDisabled}
  244. >
  245. {({isOpen}) => (
  246. <DropdownButton
  247. isOpen={isOpen}
  248. size="xs"
  249. data-test-id="add-member"
  250. disabled={isDropdownDisabled}
  251. >
  252. {t('Add Member')}
  253. </DropdownButton>
  254. )}
  255. </DropdownAutoComplete>
  256. );
  257. }
  258. render() {
  259. if (this.state.loading) {
  260. return <LoadingIndicator />;
  261. }
  262. if (this.state.error) {
  263. return <LoadingError onRetry={this.fetchData} />;
  264. }
  265. const {organization, config, team} = this.props;
  266. const {teamMembersPageLinks} = this.state;
  267. const {access} = organization;
  268. const hasWriteAccess = access.includes('org:write') || access.includes('team:admin');
  269. // TODO(team-roles): team admins can also manage membership
  270. // org:admin is a unique scope that only org owners have
  271. const isOrgOwner = access.includes('org:admin');
  272. return (
  273. <Fragment>
  274. <Panel>
  275. <PanelHeader hasButtons>
  276. <div>{t('Members')}</div>
  277. <div style={{textTransform: 'none'}}>
  278. {this.renderDropdown(hasWriteAccess, isOrgOwner)}
  279. </div>
  280. </PanelHeader>
  281. {this.state.teamMembers.length ? (
  282. this.state.teamMembers.map(member => {
  283. return (
  284. <TeamMembersRow
  285. key={member.id}
  286. hasWriteAccess={hasWriteAccess}
  287. isOrgOwner={isOrgOwner}
  288. team={team}
  289. member={member}
  290. organization={organization}
  291. removeMember={this.removeTeamMember}
  292. updateMemberRole={this.updateTeamMemberRole}
  293. user={config.user}
  294. />
  295. );
  296. })
  297. ) : (
  298. <EmptyMessage icon={<IconUser size="xl" />} size="large">
  299. {t('This team has no members')}
  300. </EmptyMessage>
  301. )}
  302. </Panel>
  303. <Pagination pageLinks={teamMembersPageLinks} />
  304. </Fragment>
  305. );
  306. }
  307. }
  308. const StyledUserListElement = styled('div')`
  309. display: grid;
  310. grid-template-columns: max-content 1fr;
  311. gap: ${space(0.5)};
  312. align-items: center;
  313. `;
  314. const StyledNameOrEmail = styled('div')`
  315. font-size: ${p => p.theme.fontSizeSmall};
  316. ${p => p.theme.overflowEllipsis};
  317. `;
  318. const StyledAvatar = styled(props => <UserAvatar {...props} />)`
  319. min-width: 1.75em;
  320. min-height: 1.75em;
  321. width: 1.5em;
  322. height: 1.5em;
  323. `;
  324. const StyledMembersLabel = styled('div')`
  325. display: grid;
  326. grid-template-columns: 1fr max-content;
  327. padding: ${space(1)} 0;
  328. font-size: ${p => p.theme.fontSizeExtraSmall};
  329. text-transform: uppercase;
  330. `;
  331. const StyledCreateMemberLink = styled(Link)`
  332. text-transform: none;
  333. `;
  334. export default withConfig(withApi(withOrganization(TeamMembers)));