Files
2024-02-15 17:01:50 +00:00

332 lines
9.4 KiB
TypeScript

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, Member, Organization, Project, Team, User} from 'sentry/types';
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<Owner>) {
return (
<ValueWrapper onClick={removeProps.onClick}>
<ActorAvatar actor={data.actor} size={28} />
</ValueWrapper>
);
}
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<Props, State> {
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<any>();
renderUserBadge = (user: User) => (
<IdBadge avatarSize={24} user={user} hideEmail useLink={false} />
);
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: (
<DisabledLabel>
<Tooltip
position="left"
title={t('%s is not a member of project', user.name || user.email)}
>
{this.renderUserBadge(user)}
</Tooltip>
</DisabledLabel>
),
});
createMentionableTeam = (team: Team): Owner => ({
value: buildTeamId(team.id),
label: <IdBadge team={team} />,
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: (
<Container>
<DisabledLabel>
<Tooltip
position="left"
title={t('%s is not a member of project', `#${team.slug}`)}
>
<IdBadge team={team} />
</Tooltip>
</DisabledLabel>
<Tooltip
title={
canAddTeam
? t('Add %s to project', `#${team.slug}`)
: t('You do not have permission to add team to project.')
}
>
<AddToProjectButton
size="zero"
borderless
disabled={!canAddTeam}
onClick={this.handleAddTeamToProject.bind(this, team)}
icon={<IconAdd isCircled />}
aria-label={t('Add %s to project', `#${team.slug}`)}
/>
</Tooltip>
</Container>
),
};
};
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 (
<SelectControl
multiple
name="owners"
filterOption={(option, filterText) =>
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)};
`;