import {Component} from 'react'; import {RouteComponentProps} from 'react-router'; import styled from '@emotion/styled'; import debounce from 'lodash/debounce'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import { openInviteMembersModal, openTeamAccessRequestModal, } from 'sentry/actionCreators/modal'; import {joinTeam, leaveTeam} from 'sentry/actionCreators/teams'; import {Client} from 'sentry/api'; import UserAvatar from 'sentry/components/avatar/userAvatar'; import Button from 'sentry/components/button'; import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete'; import {Item} from 'sentry/components/dropdownAutoComplete/types'; import DropdownButton from 'sentry/components/dropdownButton'; import IdBadge from 'sentry/components/idBadge'; import Link from 'sentry/components/links/link'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {Panel, PanelHeader, PanelItem} from 'sentry/components/panels'; import {IconSubtract, IconUser} from 'sentry/icons'; import {t} from 'sentry/locale'; import space from 'sentry/styles/space'; import {Config, Member, Organization} from 'sentry/types'; import withApi from 'sentry/utils/withApi'; import withConfig from 'sentry/utils/withConfig'; import withOrganization from 'sentry/utils/withOrganization'; import EmptyMessage from 'sentry/views/settings/components/emptyMessage'; type RouteParams = { orgId: string; teamId: string; }; type Props = { api: Client; config: Config; organization: Organization; } & RouteComponentProps; type State = { dropdownBusy: boolean; error: boolean; loading: boolean; orgMemberList: Member[]; teamMemberList: Member[]; }; class TeamMembers extends Component { state: State = { loading: true, error: false, dropdownBusy: false, teamMemberList: [], orgMemberList: [], }; componentDidMount() { this.fetchData(); } UNSAFE_componentWillReceiveProps(nextProps: Props) { const params = this.props.params; if ( nextProps.params.teamId !== params.teamId || nextProps.params.orgId !== params.orgId ) { this.setState( { loading: true, error: false, }, this.fetchData ); } } debouncedFetchMembersRequest = debounce( (query: string) => this.setState({dropdownBusy: true}, () => this.fetchMembersRequest(query)), 200 ); removeMember(member: Member) { const {params} = this.props; leaveTeam( this.props.api, { orgId: params.orgId, teamId: params.teamId, memberId: member.id, }, { success: () => { this.setState({ teamMemberList: this.state.teamMemberList.filter(m => m.id !== member.id), }); addSuccessMessage(t('Successfully removed member from team.')); }, error: () => addErrorMessage( t('There was an error while trying to remove a member from the team.') ), } ); } fetchMembersRequest = async (query: string) => { const {params, api} = this.props; const {orgId} = params; try { const data = await api.requestPromise(`/organizations/${orgId}/members/`, { query: {query}, }); this.setState({ orgMemberList: data, dropdownBusy: false, }); } catch (_err) { addErrorMessage(t('Unable to load organization members.'), { duration: 2000, }); this.setState({ dropdownBusy: false, }); } }; fetchData = async () => { const {api, params} = this.props; try { const data = await api.requestPromise( `/teams/${params.orgId}/${params.teamId}/members/` ); this.setState({ teamMemberList: data, loading: false, error: false, }); } catch (err) { this.setState({ loading: false, error: true, }); } this.fetchMembersRequest(''); }; addTeamMember = (selection: Item) => { const {params} = this.props; this.setState({loading: true}); // Reset members list after adding member to team this.debouncedFetchMembersRequest(''); joinTeam( this.props.api, { orgId: params.orgId, teamId: params.teamId, memberId: selection.value, }, { success: () => { const orgMember = this.state.orgMemberList.find( member => member.id === selection.value ); if (orgMember === undefined) { return; } this.setState({ loading: false, error: false, teamMemberList: this.state.teamMemberList.concat([orgMember]), }); addSuccessMessage(t('Successfully added member to team.')); }, error: () => { this.setState({ loading: false, }); addErrorMessage(t('Unable to add team member.')); }, } ); }; /** * We perform an API request to support orgs with > 100 members (since that's the max API returns) * * @param {Event} e React Event when member filter input changes */ handleMemberFilterChange = (e: React.ChangeEvent) => { this.setState({dropdownBusy: true}); this.debouncedFetchMembersRequest(e.target.value); }; renderDropdown(hasWriteAccess: boolean) { const {organization, params} = this.props; const existingMembers = new Set(this.state.teamMemberList.map(member => member.id)); // members can add other members to a team if the `Open Membership` setting is enabled // otherwise, `org:write` or `team:admin` permissions are required const hasOpenMembership = !!organization?.openMembership; const canAddMembers = hasOpenMembership || hasWriteAccess; const items = (this.state.orgMemberList || []) .filter(m => !existingMembers.has(m.id)) .map(m => ({ searchKey: `${m.name} ${m.email}`, value: m.id, label: ( {m.name || m.email} ), })); const menuHeader = ( {t('Members')} openInviteMembersModal({source: 'teams'})} data-test-id="invite-member" > {t('Invite Member')} ); return ( openTeamAccessRequestModal({ teamId: params.teamId, orgId: params.orgId, memberId: selection.value, }) } menuHeader={menuHeader} emptyMessage={t('No members')} onChange={this.handleMemberFilterChange} busy={this.state.dropdownBusy} onClose={() => this.debouncedFetchMembersRequest('')} > {({isOpen}) => ( {t('Add Member')} )} ); } removeButton(member: Member) { return ( ); } render() { if (this.state.loading) { return ; } if (this.state.error) { return ; } const {params, organization, config} = this.props; const {access} = organization; const hasWriteAccess = access.includes('org:write') || access.includes('team:admin'); return (
{t('Members')}
{this.renderDropdown(hasWriteAccess)}
{this.state.teamMemberList.length ? ( this.state.teamMemberList.map(member => { const isSelf = member.email === config.user.email; const canRemoveMember = hasWriteAccess || isSelf; return ( {canRemoveMember && this.removeButton(member)} ); }) ) : ( } size="large"> {t('This team has no members')} )}
); } } const StyledMemberContainer = styled(PanelItem)` justify-content: space-between; align-items: center; `; const StyledUserListElement = styled('div')` display: grid; grid-template-columns: max-content 1fr; gap: ${space(0.5)}; align-items: center; `; const StyledNameOrEmail = styled('div')` font-size: ${p => p.theme.fontSizeSmall}; ${p => p.theme.overflowEllipsis}; `; const StyledAvatar = styled(props => )` min-width: 1.75em; min-height: 1.75em; width: 1.5em; height: 1.5em; `; const StyledMembersLabel = styled('div')` display: grid; grid-template-columns: 1fr max-content; padding: ${space(1)} 0; font-size: ${p => p.theme.fontSizeExtraSmall}; text-transform: uppercase; `; const StyledCreateMemberLink = styled(Link)` text-transform: none; `; export default withConfig(withApi(withOrganization(TeamMembers)));