123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334 |
- 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<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 disableLink />
- );
- 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)};
- `;
|