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

231 lines
5.9 KiB
TypeScript

import {Component} from 'react';
import styled from '@emotion/styled';
import debounce from 'lodash/debounce';
import type {Client} from 'sentry/api';
import SelectControl from 'sentry/components/forms/controls/selectControl';
import IdBadge from 'sentry/components/idBadge';
import {Tooltip} from 'sentry/components/tooltip';
import {t} from 'sentry/locale';
import MemberListStore from 'sentry/stores/memberListStore';
import type {Member, Organization, Project, User} from 'sentry/types';
import withApi from 'sentry/utils/withApi';
const getSearchKeyForUser = (user: User) =>
`${user.email?.toLowerCase()} ${user.name?.toLowerCase()}`;
type MentionableUser = {
actor: {
id: string;
name: string;
type: 'user';
};
label: React.ReactElement;
searchKey: string;
value: string;
disabled?: boolean;
};
type Props = {
api: Client;
onChange: (value: any) => any;
organization: Organization;
value: any;
disabled?: boolean;
onInputChange?: (value: any) => any;
placeholder?: string;
project?: Project;
styles?: {control?: (provided: any) => any};
};
type State = {
inputValue: string;
loading: boolean;
memberListLoading: boolean;
options: MentionableUser[] | null;
};
type FilterOption<T> = {
data: T;
label: React.ReactNode;
value: string;
};
/**
* A component that allows you to select either members and/or teams
*/
class SelectMembers extends Component<Props, State> {
state: State = {
loading: false,
inputValue: '',
options: null,
memberListLoading: MemberListStore.state.loading,
};
componentWillUnmount() {
this.unlisteners.forEach(listener => {
if (typeof listener === 'function') {
listener();
}
});
}
unlisteners = [
MemberListStore.listen(
() => this.setState({memberListLoading: MemberListStore.state.loading}),
undefined
),
];
renderUserBadge = (user: User) => (
<IdBadge avatarSize={24} user={user} hideEmail useLink={false} />
);
createMentionableUser = (user: User): MentionableUser => ({
value: user.id,
label: this.renderUserBadge(user),
searchKey: getSearchKeyForUser(user),
actor: {
type: 'user',
id: user.id,
name: user.name,
},
});
createUnmentionableUser = ({user}) => ({
...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>
),
});
getMentionableUsers() {
return MemberListStore.getAll().map(this.createMentionableUser);
}
handleChange = newValue => {
this.props.onChange(newValue);
};
handleInputChange = inputValue => {
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, `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 = (): Promise<MentionableUser[]> => {
const usersInProject = this.getMentionableUsers();
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)
: []) as MentionableUser[]
)
.then((members: MentionableUser[]) => {
const options = [...usersInProject, ...members];
this.setState({options});
return options;
});
};
render() {
const {placeholder, styles} = this.props;
// If memberList is still loading we need to disable a placeholder Select,
// otherwise `react-select` will call `loadOptions` and prematurely load
// options
if (this.state.memberListLoading) {
return <StyledSelectControl isDisabled placeholder={t('Loading')} />;
}
return (
<StyledSelectControl
filterOption={(option: FilterOption<MentionableUser>, filterText: string) =>
option?.data?.searchKey?.indexOf(filterText) > -1
}
loadOptions={this.handleLoadOptions}
defaultOptions
async
isDisabled={this.props.disabled}
cacheOptions={false}
placeholder={placeholder}
onInputChange={this.handleInputChange}
onChange={this.handleChange}
value={this.state.options?.find(({value}) => value === this.props.value)}
styles={{
...(styles ?? {}),
option: (provided, state: any) => ({
...provided,
svg: {
color: state.isSelected && state.theme.white,
},
}),
}}
/>
);
}
}
const DisabledLabel = styled('div')`
display: flex;
opacity: 0.5;
overflow: hidden; /* Needed so that "Add to team" button can fit */
`;
const StyledSelectControl = styled(SelectControl)`
.Select-value {
display: flex;
align-items: center;
}
.Select-input {
margin-left: 32px;
}
`;
export default withApi(SelectMembers);