index.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  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.isLoaded(),
  57. };
  58. componentWillUnmount() {
  59. this.unlisteners.forEach(callIfFunction);
  60. }
  61. unlisteners = [
  62. MemberListStore.listen(() => {
  63. this.setState({
  64. memberListLoading: !MemberListStore.isLoaded(),
  65. });
  66. }, undefined),
  67. ];
  68. renderUserBadge = (user: User) => (
  69. <IdBadge avatarSize={24} user={user} hideEmail useLink={false} />
  70. );
  71. createMentionableUser = (user: User): MentionableUser => ({
  72. value: user.id,
  73. label: this.renderUserBadge(user),
  74. searchKey: getSearchKeyForUser(user),
  75. actor: {
  76. type: 'user',
  77. id: user.id,
  78. name: user.name,
  79. },
  80. });
  81. createUnmentionableUser = ({user}) => ({
  82. ...this.createMentionableUser(user),
  83. disabled: true,
  84. label: (
  85. <DisabledLabel>
  86. <Tooltip
  87. position="left"
  88. title={t('%s is not a member of project', user.name || user.email)}
  89. >
  90. {this.renderUserBadge(user)}
  91. </Tooltip>
  92. </DisabledLabel>
  93. ),
  94. });
  95. getMentionableUsers() {
  96. return MemberListStore.getAll().map(this.createMentionableUser);
  97. }
  98. handleChange = newValue => {
  99. this.props.onChange(newValue);
  100. };
  101. handleInputChange = inputValue => {
  102. this.setState({inputValue});
  103. if (this.props.onInputChange) {
  104. this.props.onInputChange(inputValue);
  105. }
  106. };
  107. queryMembers = debounce((query, cb) => {
  108. const {api, organization} = this.props;
  109. // Because this function is debounced, the component can potentially be
  110. // unmounted before this fires, in which case, `api` is null
  111. if (!api) {
  112. return null;
  113. }
  114. return api
  115. .requestPromise(`/organizations/${organization.slug}/members/`, {
  116. query: {query},
  117. })
  118. .then(
  119. (data: Member[]) => cb(null, data),
  120. err => cb(err)
  121. );
  122. }, 250);
  123. handleLoadOptions = (): Promise<MentionableUser[]> => {
  124. const usersInProject = this.getMentionableUsers();
  125. const usersInProjectById = usersInProject.map(({actor}) => actor.id);
  126. // Return a promise for `react-select`
  127. return new Promise((resolve, reject) => {
  128. this.queryMembers(this.state.inputValue, (err, result) => {
  129. if (err) {
  130. reject(err);
  131. } else {
  132. resolve(result);
  133. }
  134. });
  135. })
  136. .then(
  137. members =>
  138. // Be careful here as we actually want the `users` object, otherwise it means user
  139. // has not registered for sentry yet, but has been invited
  140. (members
  141. ? (members as Member[])
  142. .filter(({user}) => user && usersInProjectById.indexOf(user.id) === -1)
  143. .map(this.createUnmentionableUser)
  144. : []) as MentionableUser[]
  145. )
  146. .then((members: MentionableUser[]) => {
  147. const options = [...usersInProject, ...members];
  148. this.setState({options});
  149. return options;
  150. });
  151. };
  152. render() {
  153. const {placeholder, styles} = this.props;
  154. // If memberList is still loading we need to disable a placeholder Select,
  155. // otherwise `react-select` will call `loadOptions` and prematurely load
  156. // options
  157. if (this.state.memberListLoading) {
  158. return <StyledSelectControl isDisabled placeholder={t('Loading')} />;
  159. }
  160. return (
  161. <StyledSelectControl
  162. filterOption={(option: FilterOption<MentionableUser>, filterText: string) =>
  163. option?.data?.searchKey?.indexOf(filterText) > -1
  164. }
  165. loadOptions={this.handleLoadOptions}
  166. defaultOptions
  167. async
  168. isDisabled={this.props.disabled}
  169. cacheOptions={false}
  170. placeholder={placeholder}
  171. onInputChange={this.handleInputChange}
  172. onChange={this.handleChange}
  173. value={this.state.options?.find(({value}) => value === this.props.value)}
  174. styles={{
  175. ...(styles ?? {}),
  176. option: (provided, state: any) => ({
  177. ...provided,
  178. svg: {
  179. color: state.isSelected && state.theme.white,
  180. },
  181. }),
  182. }}
  183. />
  184. );
  185. }
  186. }
  187. const DisabledLabel = styled('div')`
  188. display: flex;
  189. opacity: 0.5;
  190. overflow: hidden; /* Needed so that "Add to team" button can fit */
  191. `;
  192. const StyledSelectControl = styled(SelectControl)`
  193. .Select-value {
  194. display: flex;
  195. align-items: center;
  196. }
  197. .Select-input {
  198. margin-left: 32px;
  199. }
  200. `;
  201. export default withApi(SelectMembers);