import {Component, createRef} from 'react'; import {findDOMNode} from 'react-dom'; import type {MultiValueProps} from 'react-select'; import styled from '@emotion/styled'; import debounce from 'lodash/debounce'; import isEqual from 'lodash/isEqual'; import {addTeamToProject} from 'sentry/actionCreators/projects'; import type {Client} from 'sentry/api'; import ActorAvatar from 'sentry/components/avatar/actorAvatar'; import {Button} from 'sentry/components/button'; import SelectControl from 'sentry/components/forms/controls/selectControl'; import IdBadge from 'sentry/components/idBadge'; import {Tooltip} from 'sentry/components/tooltip'; import {IconAdd} from 'sentry/icons'; import {t} from 'sentry/locale'; import MemberListStore from 'sentry/stores/memberListStore'; import ProjectsStore from 'sentry/stores/projectsStore'; import TeamStore from 'sentry/stores/teamStore'; import {space} from 'sentry/styles/space'; import type {Actor} from 'sentry/types/core'; import type {Member, Organization, Team} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import type {User} from 'sentry/types/user'; import {buildTeamId, buildUserId} from 'sentry/utils'; import withApi from 'sentry/utils/withApi'; import withProjects from 'sentry/utils/withProjects'; export type Owner = { actor: Actor; label: React.ReactNode; searchKey: string; value: string; disabled?: boolean; }; function ValueComponent({data, removeProps}: MultiValueProps) { return ( ); } const getSearchKeyForUser = (user: User) => `${user.email?.toLowerCase()} ${user.name?.toLowerCase()}`; type Props = { api: Client; disabled: boolean; onChange: (owners: Owner[]) => void; organization: Organization; project: Project; projects: Project[]; value: any; onInputChange?: (text: string) => void; }; type State = { inputValue: string; loading: boolean; }; class SelectOwners extends Component { state: State = { loading: false, inputValue: '', }; componentDidUpdate(prevProps: Props) { // Once a team has been added to the project the menu can be closed. if (!isEqual(this.props.projects, prevProps.projects)) { this.closeSelectMenu(); } } private selectRef = createRef(); renderUserBadge = (user: User) => ( ); createMentionableUser = (user: User): Owner => ({ value: buildUserId(user.id), label: this.renderUserBadge(user), searchKey: getSearchKeyForUser(user), actor: { type: 'user' as const, id: user.id, name: user.name, }, }); createUnmentionableUser = ({user}): Owner => ({ ...this.createMentionableUser(user), disabled: true, label: ( {this.renderUserBadge(user)} ), }); createMentionableTeam = (team: Team): Owner => ({ value: buildTeamId(team.id), label: , searchKey: `#${team.slug}`, actor: { type: 'team' as const, id: team.id, name: team.slug, }, }); createUnmentionableTeam = (team: Team): Owner => { const {organization} = this.props; const canAddTeam = organization.access.includes('project:write'); return { ...this.createMentionableTeam(team), disabled: true, label: ( } aria-label={t('Add %s to project', `#${team.slug}`)} /> ), }; }; getMentionableUsers() { return MemberListStore.getAll().map(this.createMentionableUser); } getMentionableTeams() { const {project} = this.props; const projectData = ProjectsStore.getBySlug(project.slug); if (!projectData) { return []; } return projectData.teams.map(this.createMentionableTeam); } /** * Get list of teams that are not in the current project, for use in `MultiSelectMenu` */ getTeamsNotInProject(teamsInProject: Owner[] = []) { const teams = TeamStore.getAll() || []; const excludedTeamIds = teamsInProject.map(({actor}) => actor.id); return teams .filter(team => !excludedTeamIds.includes(team.id)) .map(this.createUnmentionableTeam); } /** * Closes the select menu by blurring input if possible since that seems to be the only * way to close it. */ closeSelectMenu() { // Close select menu if (this.selectRef.current) { // eslint-disable-next-line react/no-find-dom-node const node = findDOMNode(this.selectRef.current); const input: HTMLInputElement | null = (node as Element)?.querySelector( '.Select-input input' ); if (input) { // I don't think there's another way to close `react-select` input.blur(); } } } async handleAddTeamToProject(team) { const {api, organization, project, value} = this.props; // Copy old value const oldValue = [...value]; // Optimistic update this.props.onChange([...this.props.value, this.createMentionableTeam(team)]); try { // Try to add team to project // Note: we can't close select menu here because we have to wait for ProjectsStore to update first // The reason for this is because we have little control over `react-select`'s `AsyncSelect` // We can't control when `handleLoadOptions` gets called, but it gets called when select closes, so // wait for store to update before closing the menu. Otherwise, we'll have stale items in the select menu await addTeamToProject(api, organization.slug, project.slug, team); } catch (err) { // Unable to add team to project, revert select menu value this.props.onChange(oldValue); this.closeSelectMenu(); } } handleChange = (newValue: Owner[]) => { this.props.onChange(newValue); }; handleInputChange = (inputValue: string) => { this.setState({inputValue}); if (this.props.onInputChange) { this.props.onInputChange(inputValue); } }; queryMembers = debounce((query, cb) => { const {api, organization} = this.props; // Because this function is debounced, the component can potentially be // unmounted before this fires, in which case, `this.api` is null if (!api) { return null; } return api .requestPromise(`/organizations/${organization.slug}/members/`, { query: {query}, }) .then( (data: Member[]) => cb(null, data), err => cb(err) ); }, 250); handleLoadOptions = () => { const usersInProject = this.getMentionableUsers(); const teamsInProject = this.getMentionableTeams(); const teamsNotInProject = this.getTeamsNotInProject(teamsInProject); const usersInProjectById = usersInProject.map(({actor}) => actor.id); // Return a promise for `react-select` return new Promise((resolve, reject) => { this.queryMembers(this.state.inputValue, (err, result) => { if (err) { reject(err); } else { resolve(result); } }); }) .then(members => // Be careful here as we actually want the `users` object, otherwise it means user // has not registered for sentry yet, but has been invited members ? (members as Member[]) .filter(({user}) => user && !usersInProjectById.includes(user.id)) .map(this.createUnmentionableUser) : [] ) .then(members => { return [...usersInProject, ...teamsInProject, ...teamsNotInProject, ...members]; }); }; render() { return ( option.data.searchKey.indexOf(filterText) > -1 } ref={this.selectRef} loadOptions={this.handleLoadOptions} defaultOptions async clearable disabled={this.props.disabled} cache={false} aria-label={t('Rule owner')} placeholder={t('Owners')} components={{ MultiValue: ValueComponent, }} onInputChange={this.handleInputChange} onChange={this.handleChange} value={this.props.value} css={{width: 300}} /> ); } } export default withApi(withProjects(SelectOwners)); const Container = styled('div')` display: flex; justify-content: space-between; `; const DisabledLabel = styled('div')` opacity: 0.5; overflow: hidden; /* Needed so that "Add to team" button can fit */ `; const AddToProjectButton = styled(Button)` flex-shrink: 0; `; const ValueWrapper = styled('a')` margin-right: ${space(0.5)}; `;