teamMembers.tsx 9.8 KB

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