teamMembers.tsx 9.8 KB

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