index.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import {Component} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import {Client} from 'sentry/api';
  5. import SelectControl from 'sentry/components/forms/controls/selectControl';
  6. import IdBadge from 'sentry/components/idBadge';
  7. import {Tooltip} from 'sentry/components/tooltip';
  8. import {t} from 'sentry/locale';
  9. import MemberListStore from 'sentry/stores/memberListStore';
  10. import {Member, Organization, Project, User} from 'sentry/types';
  11. import {callIfFunction} from 'sentry/utils/callIfFunction';
  12. import withApi from 'sentry/utils/withApi';
  13. const getSearchKeyForUser = (user: User) =>
  14. `${user.email && user.email.toLowerCase()} ${user.name && user.name.toLowerCase()}`;
  15. type MentionableUser = {
  16. actor: {
  17. id: string;
  18. name: string;
  19. type: 'user';
  20. };
  21. label: React.ReactElement;
  22. searchKey: string;
  23. value: string;
  24. disabled?: boolean;
  25. };
  26. type Props = {
  27. api: Client;
  28. onChange: (value: any) => any;
  29. organization: Organization;
  30. value: any;
  31. disabled?: boolean;
  32. onInputChange?: (value: any) => any;
  33. placeholder?: string;
  34. project?: Project;
  35. styles?: {control?: (provided: any) => any};
  36. };
  37. type State = {
  38. inputValue: string;
  39. loading: boolean;
  40. memberListLoading: boolean;
  41. options: MentionableUser[] | null;
  42. };
  43. type FilterOption<T> = {
  44. data: T;
  45. label: React.ReactNode;
  46. value: string;
  47. };
  48. /**
  49. * A component that allows you to select either members and/or teams
  50. */
  51. class SelectMembers extends Component<Props, State> {
  52. state: State = {
  53. loading: false,
  54. inputValue: '',
  55. options: null,
  56. memberListLoading: MemberListStore.state.loading,
  57. };
  58. componentWillUnmount() {
  59. this.unlisteners.forEach(callIfFunction);
  60. }
  61. unlisteners = [
  62. MemberListStore.listen(
  63. () => this.setState({memberListLoading: MemberListStore.state.loading}),
  64. undefined
  65. ),
  66. ];
  67. renderUserBadge = (user: User) => (
  68. <IdBadge avatarSize={24} user={user} hideEmail useLink={false} />
  69. );
  70. createMentionableUser = (user: User): MentionableUser => ({
  71. value: user.id,
  72. label: this.renderUserBadge(user),
  73. searchKey: getSearchKeyForUser(user),
  74. actor: {
  75. type: 'user',
  76. id: user.id,
  77. name: user.name,
  78. },
  79. });
  80. createUnmentionableUser = ({user}) => ({
  81. ...this.createMentionableUser(user),
  82. disabled: true,
  83. label: (
  84. <DisabledLabel>
  85. <Tooltip
  86. position="left"
  87. title={t('%s is not a member of project', user.name || user.email)}
  88. >
  89. {this.renderUserBadge(user)}
  90. </Tooltip>
  91. </DisabledLabel>
  92. ),
  93. });
  94. getMentionableUsers() {
  95. return MemberListStore.getAll().map(this.createMentionableUser);
  96. }
  97. handleChange = newValue => {
  98. this.props.onChange(newValue);
  99. };
  100. handleInputChange = inputValue => {
  101. this.setState({inputValue});
  102. if (this.props.onInputChange) {
  103. this.props.onInputChange(inputValue);
  104. }
  105. };
  106. queryMembers = debounce((query, cb) => {
  107. const {api, organization} = this.props;
  108. // Because this function is debounced, the component can potentially be
  109. // unmounted before this fires, in which case, `api` is null
  110. if (!api) {
  111. return null;
  112. }
  113. return api
  114. .requestPromise(`/organizations/${organization.slug}/members/`, {
  115. query: {query},
  116. })
  117. .then(
  118. (data: Member[]) => cb(null, data),
  119. err => cb(err)
  120. );
  121. }, 250);
  122. handleLoadOptions = (): Promise<MentionableUser[]> => {
  123. const usersInProject = this.getMentionableUsers();
  124. const usersInProjectById = usersInProject.map(({actor}) => actor.id);
  125. // Return a promise for `react-select`
  126. return new Promise((resolve, reject) => {
  127. this.queryMembers(this.state.inputValue, (err, result) => {
  128. if (err) {
  129. reject(err);
  130. } else {
  131. resolve(result);
  132. }
  133. });
  134. })
  135. .then(
  136. members =>
  137. // Be careful here as we actually want the `users` object, otherwise it means user
  138. // has not registered for sentry yet, but has been invited
  139. (members
  140. ? (members as Member[])
  141. .filter(({user}) => user && !usersInProjectById.includes(user.id))
  142. .map(this.createUnmentionableUser)
  143. : []) as MentionableUser[]
  144. )
  145. .then((members: MentionableUser[]) => {
  146. const options = [...usersInProject, ...members];
  147. this.setState({options});
  148. return options;
  149. });
  150. };
  151. render() {
  152. const {placeholder, styles} = this.props;
  153. // If memberList is still loading we need to disable a placeholder Select,
  154. // otherwise `react-select` will call `loadOptions` and prematurely load
  155. // options
  156. if (this.state.memberListLoading) {
  157. return <StyledSelectControl isDisabled placeholder={t('Loading')} />;
  158. }
  159. return (
  160. <StyledSelectControl
  161. filterOption={(option: FilterOption<MentionableUser>, filterText: string) =>
  162. option?.data?.searchKey?.indexOf(filterText) > -1
  163. }
  164. loadOptions={this.handleLoadOptions}
  165. defaultOptions
  166. async
  167. isDisabled={this.props.disabled}
  168. cacheOptions={false}
  169. placeholder={placeholder}
  170. onInputChange={this.handleInputChange}
  171. onChange={this.handleChange}
  172. value={this.state.options?.find(({value}) => value === this.props.value)}
  173. styles={{
  174. ...(styles ?? {}),
  175. option: (provided, state: any) => ({
  176. ...provided,
  177. svg: {
  178. color: state.isSelected && state.theme.white,
  179. },
  180. }),
  181. }}
  182. />
  183. );
  184. }
  185. }
  186. const DisabledLabel = styled('div')`
  187. display: flex;
  188. opacity: 0.5;
  189. overflow: hidden; /* Needed so that "Add to team" button can fit */
  190. `;
  191. const StyledSelectControl = styled(SelectControl)`
  192. .Select-value {
  193. display: flex;
  194. align-items: center;
  195. }
  196. .Select-input {
  197. margin-left: 32px;
  198. }
  199. `;
  200. export default withApi(SelectMembers);