index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import React from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import {addTeamToProject} from 'app/actionCreators/projects';
  5. import {Client} from 'app/api';
  6. import Button from 'app/components/button';
  7. import SelectControl from 'app/components/forms/selectControl';
  8. import IdBadge from 'app/components/idBadge';
  9. import Tooltip from 'app/components/tooltip';
  10. import {IconAdd, IconUser} from 'app/icons';
  11. import {t} from 'app/locale';
  12. import MemberListStore from 'app/stores/memberListStore';
  13. import ProjectsStore from 'app/stores/projectsStore';
  14. import TeamStore from 'app/stores/teamStore';
  15. import space from 'app/styles/space';
  16. import {Member, Organization, Project, Team, User} from 'app/types';
  17. import {callIfFunction} from 'app/utils/callIfFunction';
  18. import withApi from 'app/utils/withApi';
  19. const getSearchKeyForUser = (user: User) =>
  20. `${user.email && user.email.toLowerCase()} ${user.name && user.name.toLowerCase()}`;
  21. type Actor<T> = {
  22. type: T;
  23. id: string;
  24. name: string;
  25. };
  26. type Mentionable<T> = {
  27. value: string;
  28. label: React.ReactElement;
  29. searchKey: string;
  30. actor: Actor<T>;
  31. };
  32. const UnassignedWrapper = styled('div')`
  33. display: flex;
  34. align-items: center;
  35. `;
  36. const StyledIconUser = styled(IconUser)`
  37. margin-left: ${space(0.25)};
  38. margin-right: ${space(1)};
  39. color: ${p => p.theme.gray400};
  40. `;
  41. const unassignedOption = {
  42. value: null,
  43. label: (
  44. <UnassignedWrapper>
  45. <StyledIconUser size="20px" />
  46. {t('Unassigned')}
  47. </UnassignedWrapper>
  48. ),
  49. searchKey: 'unassigned',
  50. actor: null,
  51. };
  52. type MentionableUnassigned = typeof unassignedOption;
  53. type Unmentionable = {
  54. disabled: boolean;
  55. label: React.ReactElement;
  56. };
  57. type MentionableTeam = Mentionable<'team'>;
  58. type UnmentionableTeam = MentionableTeam & Unmentionable;
  59. type MentionableUser = Mentionable<'user'>;
  60. type UnmentionableUser = MentionableUser & Unmentionable;
  61. type AllMentionable = (MentionableUser | MentionableTeam | MentionableUnassigned) &
  62. Partial<Unmentionable>;
  63. type Props = {
  64. api: Client;
  65. project?: Project;
  66. organization: Organization;
  67. value: any;
  68. showTeam: boolean;
  69. onChange: (value: any) => any;
  70. onInputChange?: (value: any) => any;
  71. disabled?: boolean;
  72. placeholder?: string;
  73. styles?: {control?: (provided: any) => any};
  74. filteredTeamIds?: Set<string>;
  75. // Used to display an additional unassigned option
  76. includeUnassigned?: boolean;
  77. };
  78. type State = {
  79. loading: boolean;
  80. memberListLoading: boolean;
  81. inputValue: string;
  82. options: AllMentionable[] | null;
  83. };
  84. type FilterOption<T> = {
  85. value: string;
  86. label: React.ReactNode;
  87. data: T;
  88. };
  89. /**
  90. * A component that allows you to select either members and/or teams
  91. */
  92. class SelectMembers extends React.Component<Props, State> {
  93. state: State = {
  94. loading: false,
  95. inputValue: '',
  96. options: null,
  97. memberListLoading: !MemberListStore.isLoaded(),
  98. };
  99. componentWillUnmount() {
  100. this.unlisteners.forEach(callIfFunction);
  101. }
  102. // TODO(ts) This type could be improved when react-select types are better.
  103. selectRef = React.createRef<any>();
  104. unlisteners = [
  105. MemberListStore.listen(() => {
  106. this.setState({
  107. memberListLoading: !MemberListStore.isLoaded(),
  108. });
  109. }, undefined),
  110. ];
  111. renderUserBadge = (user: User) => (
  112. <IdBadge avatarSize={24} user={user} hideEmail useLink={false} />
  113. );
  114. createMentionableUser = (user: User): MentionableUser => ({
  115. value: user.id,
  116. label: this.renderUserBadge(user),
  117. searchKey: getSearchKeyForUser(user),
  118. actor: {
  119. type: 'user',
  120. id: user.id,
  121. name: user.name,
  122. },
  123. });
  124. createUnmentionableUser = ({user}) => ({
  125. ...this.createMentionableUser(user),
  126. disabled: true,
  127. label: (
  128. <DisabledLabel>
  129. <Tooltip
  130. position="left"
  131. title={t('%s is not a member of project', user.name || user.email)}
  132. >
  133. {this.renderUserBadge(user)}
  134. </Tooltip>
  135. </DisabledLabel>
  136. ),
  137. });
  138. createMentionableTeam = (team: Team): MentionableTeam => ({
  139. value: team.id,
  140. label: <IdBadge team={team} />,
  141. searchKey: `#${team.slug}`,
  142. actor: {
  143. type: 'team',
  144. id: team.id,
  145. name: team.slug,
  146. },
  147. });
  148. createUnmentionableTeam = (team: Team): UnmentionableTeam => {
  149. const {organization} = this.props;
  150. const canAddTeam = organization.access.includes('project:write');
  151. return {
  152. ...this.createMentionableTeam(team),
  153. disabled: true,
  154. label: (
  155. <UnmentionableTeam>
  156. <DisabledLabel>
  157. <Tooltip
  158. position="left"
  159. title={t('%s is not a member of project', `#${team.slug}`)}
  160. >
  161. <IdBadge team={team} />
  162. </Tooltip>
  163. </DisabledLabel>
  164. <Tooltip
  165. title={
  166. canAddTeam
  167. ? t('Add %s to project', `#${team.slug}`)
  168. : t('You do not have permission to add team to project.')
  169. }
  170. >
  171. <AddToProjectButton
  172. type="button"
  173. size="zero"
  174. borderless
  175. disabled={!canAddTeam}
  176. onClick={this.handleAddTeamToProject.bind(this, team)}
  177. icon={<IconAdd isCircled />}
  178. />
  179. </Tooltip>
  180. </UnmentionableTeam>
  181. ),
  182. };
  183. };
  184. getMentionableUsers() {
  185. return MemberListStore.getAll().map(this.createMentionableUser);
  186. }
  187. getMentionableTeams(): MentionableTeam[] {
  188. const {project} = this.props;
  189. const projectData = project && ProjectsStore.getBySlug(project.slug);
  190. if (!projectData) {
  191. return [];
  192. }
  193. return projectData.teams.map(this.createMentionableTeam);
  194. }
  195. /**
  196. * Get list of teams that are not in the current project, for use in `MultiSelectMenu`
  197. *
  198. * @param {Team[]} teamsInProject A list of teams that are in the current project
  199. */
  200. getTeamsNotInProject(teamsInProject: MentionableTeam[] = []): UnmentionableTeam[] {
  201. const teams: Team[] = TeamStore.getAll() || [];
  202. const excludedTeamIds = teamsInProject.map(({actor}) => actor.id);
  203. return teams
  204. .filter(team => excludedTeamIds.indexOf(team.id) === -1)
  205. .map(this.createUnmentionableTeam);
  206. }
  207. /**
  208. * Closes the select menu by blurring input if possible since that seems to be the only
  209. * way to close it.
  210. */
  211. closeSelectMenu() {
  212. if (!this.selectRef.current) {
  213. return;
  214. }
  215. // @ts-ignore The types for react-select don't cover this property
  216. // or the type of selectRef is incorrect.
  217. const select = this.selectRef.current.select.select;
  218. const input: HTMLInputElement = select.inputRef;
  219. if (input) {
  220. // I don't think there's another way to close `react-select`
  221. input.blur();
  222. }
  223. }
  224. async handleAddTeamToProject(team: Team) {
  225. const {api, organization, project, value} = this.props;
  226. const {options} = this.state;
  227. // Copy old value
  228. const oldValue = value ? [...value] : {value};
  229. // Optimistic update
  230. this.props.onChange(this.createMentionableTeam(team));
  231. try {
  232. // Try to add team to project
  233. if (project) {
  234. await addTeamToProject(api, organization.slug, project.slug, team);
  235. // Remove add to project button without changing order
  236. const newOptions = options!.map(option => {
  237. if (option.actor?.id === team.id) {
  238. option.disabled = false;
  239. option.label = <IdBadge team={team} />;
  240. }
  241. return option;
  242. });
  243. this.setState({options: newOptions});
  244. }
  245. } catch (err) {
  246. // Unable to add team to project, revert select menu value
  247. this.props.onChange(oldValue);
  248. }
  249. this.closeSelectMenu();
  250. }
  251. handleChange = newValue => {
  252. this.props.onChange(newValue);
  253. };
  254. handleInputChange = inputValue => {
  255. this.setState({inputValue});
  256. if (this.props.onInputChange) {
  257. this.props.onInputChange(inputValue);
  258. }
  259. };
  260. queryMembers = debounce((query, cb) => {
  261. const {api, organization} = this.props;
  262. // Because this function is debounced, the component can potentially be
  263. // unmounted before this fires, in which case, `api` is null
  264. if (!api) {
  265. return null;
  266. }
  267. return api
  268. .requestPromise(`/organizations/${organization.slug}/members/`, {
  269. query: {query},
  270. })
  271. .then(
  272. (data: Member[]) => cb(null, data),
  273. err => cb(err)
  274. );
  275. }, 250);
  276. handleLoadOptions = (): Promise<AllMentionable[]> => {
  277. const {showTeam, filteredTeamIds, includeUnassigned} = this.props;
  278. if (showTeam) {
  279. const teamsInProject = this.getMentionableTeams();
  280. const teamsNotInProject = this.getTeamsNotInProject(teamsInProject);
  281. const unfilteredOptions = [...teamsInProject, ...teamsNotInProject];
  282. const options: AllMentionable[] = filteredTeamIds
  283. ? unfilteredOptions.filter(({value}) => !value || filteredTeamIds.has(value))
  284. : unfilteredOptions;
  285. if (includeUnassigned) {
  286. options.push(unassignedOption);
  287. }
  288. this.setState({options});
  289. return Promise.resolve(options);
  290. }
  291. const usersInProject = this.getMentionableUsers();
  292. const usersInProjectById = usersInProject.map(({actor}) => actor.id);
  293. // Return a promise for `react-select`
  294. return new Promise((resolve, reject) => {
  295. this.queryMembers(this.state.inputValue, (err, result) => {
  296. if (err) {
  297. reject(err);
  298. } else {
  299. resolve(result);
  300. }
  301. });
  302. })
  303. .then(
  304. members =>
  305. // Be careful here as we actually want the `users` object, otherwise it means user
  306. // has not registered for sentry yet, but has been invited
  307. (members
  308. ? (members as Member[])
  309. .filter(({user}) => user && usersInProjectById.indexOf(user.id) === -1)
  310. .map(this.createUnmentionableUser)
  311. : []) as UnmentionableUser[]
  312. )
  313. .then((members: UnmentionableUser[]) => {
  314. const options = [...usersInProject, ...members];
  315. this.setState({options});
  316. return options;
  317. });
  318. };
  319. render() {
  320. const {placeholder, styles} = this.props;
  321. // If memberList is still loading we need to disable a placeholder Select,
  322. // otherwise `react-select` will call `loadOptions` and prematurely load
  323. // options
  324. if (this.state.memberListLoading) {
  325. return <StyledSelectControl isDisabled placeholder={t('Loading')} />;
  326. }
  327. return (
  328. <StyledSelectControl
  329. ref={this.selectRef}
  330. filterOption={(option: FilterOption<AllMentionable>, filterText: string) =>
  331. option?.data?.searchKey?.indexOf(filterText) > -1
  332. }
  333. loadOptions={this.handleLoadOptions}
  334. isOptionDisabled={option => option.disabled}
  335. defaultOptions
  336. async
  337. isDisabled={this.props.disabled}
  338. cacheOptions={false}
  339. placeholder={placeholder}
  340. onInputChange={this.handleInputChange}
  341. onChange={this.handleChange}
  342. value={this.state.options?.find(({value}) => value === this.props.value)}
  343. styles={{
  344. styles,
  345. option: (provided, state: any) => ({
  346. ...provided,
  347. svg: {
  348. color: state.isSelected && state.theme.white,
  349. },
  350. }),
  351. }}
  352. />
  353. );
  354. }
  355. }
  356. const DisabledLabel = styled('div')`
  357. display: flex;
  358. opacity: 0.5;
  359. overflow: hidden; /* Needed so that "Add to team" button can fit */
  360. `;
  361. const AddToProjectButton = styled(Button)`
  362. flex-shrink: 0;
  363. `;
  364. const UnmentionableTeam = styled('div')`
  365. display: flex;
  366. justify-content: space-between;
  367. align-items: flex-end;
  368. `;
  369. const StyledSelectControl = styled(SelectControl)`
  370. .Select-value {
  371. display: flex;
  372. align-items: center;
  373. }
  374. .Select-input {
  375. margin-left: 32px;
  376. }
  377. `;
  378. export default withApi(SelectMembers);