diff --git a/static/app/views/settings/organizationTeams/teamMembers.tsx b/static/app/views/settings/organizationTeams/teamMembers.tsx index 2960b897238b3b..934421b8ccd39a 100644 --- a/static/app/views/settings/organizationTeams/teamMembers.tsx +++ b/static/app/views/settings/organizationTeams/teamMembers.tsx @@ -1,6 +1,5 @@ -import {Fragment} from 'react'; +import {Fragment, useState} from 'react'; import styled from '@emotion/styled'; -import debounce from 'lodash/debounce'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import { @@ -25,15 +24,19 @@ import PanelHeader from 'sentry/components/panels/panelHeader'; import {TeamRoleColumnLabel} from 'sentry/components/teamRoleUtils'; import {IconUser} from 'sentry/icons'; import {t} from 'sentry/locale'; +import ConfigStore from 'sentry/stores/configStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import {space} from 'sentry/styles/space'; import type {RouteComponentProps} from 'sentry/types/legacyReactRouter'; import type {Member, Organization, Team, TeamMember} from 'sentry/types/organization'; import type {Config} from 'sentry/types/system'; -import withApi from 'sentry/utils/withApi'; -import withConfig from 'sentry/utils/withConfig'; -import withOrganization from 'sentry/utils/withOrganization'; -import type {AsyncViewState} from 'sentry/views/deprecatedAsyncView'; -import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; +import {useLocation} from 'sentry/utils/useLocation'; +import useOrganization from 'sentry/utils/useOrganization'; +import {useParams} from 'sentry/utils/useParams'; import TextBlock from 'sentry/views/settings/components/text/textBlock'; import TeamMembersRow, { GRID_TEMPLATE, @@ -53,86 +56,52 @@ interface Props extends RouteComponentProps { team: Team; } -interface State extends AsyncViewState { - dropdownBusy: boolean; - error: boolean; - orgMembers: Member[]; +function AddMemberDropdown({ + teamMembers, + organization, + team, + teamId, + isTeamAdmin, +}: { + isTeamAdmin: boolean; + organization: Organization; + team: Team; + teamId: string; teamMembers: TeamMember[]; -} - -class TeamMembers extends DeprecatedAsyncView { - getDefaultState() { - return { - ...super.getDefaultState(), - error: false, - dropdownBusy: false, - teamMembers: [], - orgMembers: [], - }; - } - - componentDidMount() { - super.componentDidMount(); - // Initialize "add member" dropdown with data - this.fetchMembersRequest(''); - } - - debouncedFetchMembersRequest = debounce( - (query: string) => - this.setState({dropdownBusy: true}, () => this.fetchMembersRequest(query)), - 200 +}) { + const api = useApi({persistInFlight: true}); + const [memberQuery, setMemberQuery] = useState(''); + const debouncedMemberQuery = useDebouncedValue(memberQuery, 50); + const {data: orgMembers = [], isLoading: isOrgMembersLoading} = useApiQuery( + [ + `/organizations/${organization.slug}/members/`, + { + query: debouncedMemberQuery ? {query: debouncedMemberQuery} : undefined, + }, + ], + { + staleTime: 0, + } ); - fetchMembersRequest = async (query: string) => { - const {organization, api} = this.props; - - try { - const data = await api.requestPromise( - `/organizations/${organization.slug}/members/`, - { - query: {query}, - } - ); - this.setState({ - orgMembers: data, - dropdownBusy: false, - }); - } catch (_err) { - addErrorMessage(t('Unable to load organization members.'), { - duration: 2000, - }); + const existingMembers = new Set(teamMembers.map(member => member.id)); - this.setState({ - dropdownBusy: false, - }); - } - }; - - getEndpoints(): ReturnType { - const {organization, params} = this.props; - - return [ - [ - 'teamMembers', - `/teams/${organization.slug}/${params.teamId}/members/`, - {}, - {paginate: true}, - ], - ]; - } + // 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 || isTeamAdmin; - addTeamMember = (selection: Item) => { - const {organization, params} = this.props; - const {orgMembers, teamMembers} = this.state; + const isDropdownDisabled = team.flags['idp:provisioned']; + const addTeamMember = (selection: Item) => { // Reset members list after adding member to team - this.debouncedFetchMembersRequest(''); + setMemberQuery(''); joinTeam( - this.props.api, + api, { orgId: organization.slug, - teamId: params.teamId, + teamId, memberId: selection.value, }, { @@ -141,36 +110,145 @@ class TeamMembers extends DeprecatedAsyncView { if (orgMember === undefined) { return; } - this.setState({ - error: false, - teamMembers: teamMembers.concat([orgMember as TeamMember]), - }); + // this.setState({ + // error: false, + // teamMembers: teamMembers.concat([orgMember as TeamMember]), + // }); addSuccessMessage(t('Successfully added member to team.')); }, - error: resp => { + error: (resp: RequestError) => { const errorMessage = resp?.responseJSON?.detail || t('Unable to add team member.'); - addErrorMessage(errorMessage); + addErrorMessage(errorMessage as string); }, } ); }; - removeTeamMember = (member: Member) => { - const {organization, params} = this.props; - const {teamMembers} = this.state; + /** + * 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 + */ + const handleMemberFilterChange = (e: React.ChangeEvent) => { + setMemberQuery(e.target.value); + // this.setState({dropdownBusy: true}); + }; + + const items = (orgMembers || []) + .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, + orgId: organization.slug, + memberId: selection.value, + }) + } + menuHeader={menuHeader} + emptyMessage={t('No members')} + onChange={handleMemberFilterChange} + onClose={() => setMemberQuery('')} + disabled={isDropdownDisabled} + data-test-id="add-member-menu" + busy={isOrgMembersLoading} + > + {({isOpen}) => ( + + {t('Add Member')} + + )} + + ); +} + +function TeamMembers({team}: Props) { + const config = useLegacyStore(ConfigStore); + const api = useApi({persistInFlight: true}); + const organization = useOrganization(); + const {teamId} = useParams<{teamId: string}>(); + const location = useLocation(); + + const { + data: teamMembers = [], + isError: isTeamMembersError, + isLoading: isTeamMembersLoading, + refetch: refetchTeamMembers, + getResponseHeader: getTeamMemberResponseHeader, + } = useApiQuery( + [ + `/teams/${organization.slug}/${teamId}/members/`, + { + query: { + cursor: location.query.cursor, + query: location.query.query, + }, + }, + ], + { + staleTime: 0, + } + ); + + if (isTeamMembersError) { + return ; + } + + const teamMembersPageLinks = getTeamMemberResponseHeader?.('Link'); + + const hasOrgWriteAccess = hasEveryAccess(['org:write'], {organization, team}); + const hasTeamAdminAccess = hasEveryAccess(['team:admin'], {organization, team}); + const isTeamAdmin = hasOrgWriteAccess || hasTeamAdminAccess; + + const removeTeamMember = (member: Member) => { leaveTeam( - this.props.api, + api, { orgId: organization.slug, - teamId: params.teamId, + teamId, memberId: member.id, }, { success: () => { - this.setState({ - teamMembers: teamMembers.filter(m => m.id !== member.id), - }); + // this.setState({ + // teamMembers: teamMembers.filter(m => m.id !== member.id), + // }); addSuccessMessage(t('Successfully removed member from team.')); }, error: () => @@ -181,22 +259,20 @@ class TeamMembers extends DeprecatedAsyncView { ); }; - updateTeamMemberRole = (member: Member, newRole: string) => { - const {organization} = this.props; - const {teamId} = this.props.params; + const updateTeamMemberRole = (member: Member, newRole: string) => { const endpoint = `/organizations/${organization.slug}/members/${member.id}/teams/${teamId}/`; - this.props.api.request(endpoint, { + api.request(endpoint, { method: 'PUT', data: {teamRole: newRole}, success: data => { - const teamMembers: any = [...this.state.teamMembers]; - const i = teamMembers.findIndex(m => m.id === member.id); - teamMembers[i] = { + const newTeamMembers = [...teamMembers]; + const i = newTeamMembers.findIndex(m => m.id === member.id); + newTeamMembers[i] = { ...member, teamRole: data.teamRole, }; - this.setState({teamMembers}); + // this.setState({teamMembers}); addSuccessMessage(t('Successfully changed role for team member.')); }, error: () => { @@ -207,93 +283,7 @@ class TeamMembers extends DeprecatedAsyncView { }); }; - /** - * 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(isTeamAdmin: boolean) { - const {organization, params, team} = this.props; - const {orgMembers} = this.state; - const existingMembers = new Set(this.state.teamMembers.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 || isTeamAdmin; - - const isDropdownDisabled = team.flags['idp:provisioned']; - - const items = (orgMembers || []) - .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: organization.slug, - memberId: selection.value, - }) - } - menuHeader={menuHeader} - emptyMessage={t('No members')} - onChange={this.handleMemberFilterChange} - busy={this.state.dropdownBusy} - onClose={() => this.debouncedFetchMembersRequest('')} - disabled={isDropdownDisabled} - data-test-id="add-member-menu" - > - {({isOpen}) => ( - - {t('Add Member')} - - )} - - ); - } - - renderPageTextBlock() { - const {organization, team} = this.props; + const renderPageTextBlock = () => { const {openMembership} = organization; const isIdpProvisioned = team.flags['idp:provisioned']; @@ -308,14 +298,10 @@ class TeamMembers extends DeprecatedAsyncView { : t( '"Open Membership" is disabled for the organization. Org Owner/Manager/Admin, or Team Admins can add members for this team.' ); - } - - renderMembers(isTeamAdmin: boolean) { - const {config, organization, team} = this.props; - - const {teamMembers, loading} = this.state; + }; - if (loading) { + const renderMembers = () => { + if (isTeamMembersLoading) { return ; } if (teamMembers.length) { @@ -328,8 +314,8 @@ class TeamMembers extends DeprecatedAsyncView { team={team} member={member} user={config.user} - removeMember={this.removeTeamMember} - updateMemberRole={this.updateTeamMemberRole} + removeMember={removeTeamMember} + updateMemberRole={updateTeamMemberRole} /> ); }); @@ -339,44 +325,38 @@ class TeamMembers extends DeprecatedAsyncView { {t('This team has no members')} ); - } - - render() { - if (this.state.error) { - return ; - } - - const {organization, team} = this.props; - const {teamMembersPageLinks} = this.state; - const {openMembership} = organization; - - const hasOrgWriteAccess = hasEveryAccess(['org:write'], {organization, team}); - const hasTeamAdminAccess = hasEveryAccess(['team:admin'], {organization, team}); - const isTeamAdmin = hasOrgWriteAccess || hasTeamAdminAccess; + }; - return ( - - {this.renderPageTextBlock()} - - - - - -
{t('Members')}
-
- -
- {this.renderDropdown(isTeamAdmin)} -
- {this.renderMembers(isTeamAdmin)} -
- -
- ); - } + return ( + + {renderPageTextBlock()} + + + + + +
{t('Members')}
+
+ +
+ + + +
+ {renderMembers()} +
+ +
+ ); } const StyledUserListElement = styled('div')` @@ -414,4 +394,4 @@ const StyledPanelHeader = styled(PanelHeader)` ${GRID_TEMPLATE} `; -export default withConfig(withApi(withOrganization(TeamMembers))); +export default TeamMembers;