assigneeSelectorDropdown.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. import {Component} from 'react';
  2. import styled from '@emotion/styled';
  3. import {assignToActor, assignToUser, clearAssignment} from 'sentry/actionCreators/group';
  4. import {openInviteMembersModal} from 'sentry/actionCreators/modal';
  5. import TeamAvatar from 'sentry/components/avatar/teamAvatar';
  6. import UserAvatar from 'sentry/components/avatar/userAvatar';
  7. import type {GetActorPropsFn} from 'sentry/components/deprecatedDropdownMenu';
  8. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  9. import {ItemsBeforeFilter} from 'sentry/components/dropdownAutoComplete/types';
  10. import Highlight from 'sentry/components/highlight';
  11. import Link from 'sentry/components/links/link';
  12. import TextOverflow from 'sentry/components/textOverflow';
  13. import {IconAdd, IconClose} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import ConfigStore from 'sentry/stores/configStore';
  16. import GroupStore from 'sentry/stores/groupStore';
  17. import MemberListStore from 'sentry/stores/memberListStore';
  18. import ProjectsStore from 'sentry/stores/projectsStore';
  19. import space from 'sentry/styles/space';
  20. import type {Actor, SuggestedOwner, SuggestedOwnerReason, Team, User} from 'sentry/types';
  21. import {buildTeamId, buildUserId, valueIsEqual} from 'sentry/utils';
  22. export type SuggestedAssignee = Actor & {
  23. assignee: AssignableTeam | User;
  24. suggestedReason: SuggestedOwnerReason;
  25. };
  26. type AssignableTeam = {
  27. display: string;
  28. email: string;
  29. id: string;
  30. team: Team;
  31. };
  32. type RenderProps = {
  33. getActorProps: GetActorPropsFn;
  34. isOpen: boolean;
  35. loading: boolean;
  36. suggestedAssignees: SuggestedAssignee[];
  37. assignedTo?: Actor;
  38. };
  39. export interface AssigneeSelectorDropdownProps {
  40. children: (props: RenderProps) => React.ReactNode;
  41. id: string;
  42. disabled?: boolean;
  43. memberList?: User[];
  44. onAssign?: (
  45. type: Actor['type'],
  46. assignee: User | Actor,
  47. suggestedAssignee?: SuggestedAssignee
  48. ) => void;
  49. }
  50. type State = {
  51. loading: boolean;
  52. assignedTo?: Actor;
  53. memberList?: User[];
  54. suggestedOwners?: SuggestedOwner[] | null;
  55. };
  56. export class AssigneeSelectorDropdown extends Component<
  57. AssigneeSelectorDropdownProps,
  58. State
  59. > {
  60. state = this.getInitialState();
  61. getInitialState() {
  62. const group = GroupStore.get(this.props.id);
  63. const memberList = MemberListStore.loaded ? MemberListStore.getAll() : undefined;
  64. const loading = GroupStore.hasStatus(this.props.id, 'assignTo');
  65. const suggestedOwners = group?.owners;
  66. return {
  67. assignedTo: group?.assignedTo,
  68. memberList,
  69. loading,
  70. suggestedOwners,
  71. };
  72. }
  73. componentWillReceiveProps(nextProps: AssigneeSelectorDropdownProps) {
  74. const loading = GroupStore.hasStatus(nextProps.id, 'assignTo');
  75. if (nextProps.id !== this.props.id || loading !== this.state.loading) {
  76. const group = GroupStore.get(this.props.id);
  77. this.setState({
  78. loading,
  79. assignedTo: group?.assignedTo,
  80. suggestedOwners: group?.owners,
  81. });
  82. }
  83. }
  84. shouldComponentUpdate(nextProps: AssigneeSelectorDropdownProps, nextState: State) {
  85. if (nextState.loading !== this.state.loading) {
  86. return true;
  87. }
  88. // If the memberList in props has changed, re-render as
  89. // props have updated, and we won't use internal state anyways.
  90. if (
  91. nextProps.memberList &&
  92. !valueIsEqual(this.props.memberList, nextProps.memberList)
  93. ) {
  94. return true;
  95. }
  96. const currentMembers = this.memberList();
  97. // XXX(billyvg): this means that once `memberList` is not-null, this component will never update due to `memberList` changes
  98. // Note: this allows us to show a "loading" state for memberList, but only before `MemberListStore.loadInitialData`
  99. // is called
  100. if (currentMembers === undefined && nextState.memberList !== currentMembers) {
  101. return true;
  102. }
  103. return !valueIsEqual(nextState.assignedTo, this.state.assignedTo, true);
  104. }
  105. componentWillUnmount() {
  106. this.unlisteners.forEach(unlistener => unlistener?.());
  107. }
  108. unlisteners = [
  109. GroupStore.listen(itemIds => this.onGroupChange(itemIds), undefined),
  110. MemberListStore.listen((users: User[]) => {
  111. this.handleMemberListUpdate(users);
  112. }, undefined),
  113. ];
  114. handleMemberListUpdate = (members: User[]) => {
  115. if (members === this.state.memberList) {
  116. return;
  117. }
  118. this.setState({memberList: members});
  119. };
  120. memberList(): User[] | undefined {
  121. return this.props.memberList ?? this.state.memberList;
  122. }
  123. onGroupChange(itemIds: Set<string>) {
  124. if (!itemIds.has(this.props.id)) {
  125. return;
  126. }
  127. const group = GroupStore.get(this.props.id);
  128. this.setState({
  129. assignedTo: group?.assignedTo,
  130. suggestedOwners: group?.owners,
  131. loading: GroupStore.hasStatus(this.props.id, 'assignTo'),
  132. });
  133. }
  134. assignableTeams(): AssignableTeam[] {
  135. const group = GroupStore.get(this.props.id);
  136. if (!group) {
  137. return [];
  138. }
  139. const teams = ProjectsStore.getBySlug(group.project.slug)?.teams ?? [];
  140. return teams
  141. .sort((a, b) => a.slug.localeCompare(b.slug))
  142. .map(team => ({
  143. id: buildTeamId(team.id),
  144. display: `#${team.slug}`,
  145. email: team.id,
  146. team,
  147. }));
  148. }
  149. assignToUser(user: User | Actor) {
  150. assignToUser({id: this.props.id, user, assignedBy: 'assignee_selector'});
  151. this.setState({loading: true});
  152. }
  153. assignToTeam(team: Team) {
  154. assignToActor({
  155. actor: {id: team.id, type: 'team'},
  156. id: this.props.id,
  157. assignedBy: 'assignee_selector',
  158. });
  159. this.setState({loading: true});
  160. }
  161. handleAssign: React.ComponentProps<typeof DropdownAutoComplete>['onSelect'] = (
  162. {value: {type, assignee}},
  163. _state,
  164. e
  165. ) => {
  166. if (type === 'member') {
  167. this.assignToUser(assignee);
  168. }
  169. if (type === 'team') {
  170. this.assignToTeam(assignee);
  171. }
  172. e?.stopPropagation();
  173. const {onAssign} = this.props;
  174. if (onAssign) {
  175. const suggestionType = type === 'member' ? 'user' : type;
  176. const suggestion = this.getSuggestedAssignees().find(
  177. actor => actor.type === suggestionType && actor.id === assignee.id
  178. );
  179. onAssign?.(type, assignee, suggestion);
  180. }
  181. };
  182. clearAssignTo = (e: React.MouseEvent<HTMLDivElement>) => {
  183. // clears assignment
  184. clearAssignment(this.props.id, 'assignee_selector');
  185. this.setState({loading: true});
  186. e.stopPropagation();
  187. };
  188. renderMemberNode(member: User, suggestedReason?: string): ItemsBeforeFilter[0] {
  189. const sessionUser = ConfigStore.get('user');
  190. const handleSelect = () => this.assignToUser(member);
  191. return {
  192. value: {type: 'member', assignee: member},
  193. searchKey: `${member.email} ${member.name}`,
  194. label: ({inputValue}) => (
  195. <MenuItemWrapper
  196. data-test-id="assignee-option"
  197. key={buildUserId(member.id)}
  198. onSelect={handleSelect}
  199. >
  200. <IconContainer>
  201. <UserAvatar user={member} size={20} />
  202. </IconContainer>
  203. <Label>
  204. <Highlight text={inputValue}>
  205. {sessionUser.id === member.id
  206. ? `${member.name || member.email} ${t('(You)')}`
  207. : member.name || member.email}
  208. </Highlight>
  209. {suggestedReason && <SuggestedReason>({suggestedReason})</SuggestedReason>}
  210. </Label>
  211. </MenuItemWrapper>
  212. ),
  213. };
  214. }
  215. renderNewMemberNodes(): ItemsBeforeFilter {
  216. const members = putSessionUserFirst(this.memberList());
  217. return members.map(member => this.renderMemberNode(member));
  218. }
  219. renderTeamNode(
  220. assignableTeam: AssignableTeam,
  221. suggestedReason?: string
  222. ): ItemsBeforeFilter[0] {
  223. const {id, display, team} = assignableTeam;
  224. const handleSelect = () => this.assignToTeam(team);
  225. return {
  226. value: {type: 'team', assignee: team},
  227. searchKey: team.slug,
  228. label: ({inputValue}) => (
  229. <MenuItemWrapper data-test-id="assignee-option" key={id} onSelect={handleSelect}>
  230. <IconContainer>
  231. <TeamAvatar team={team} size={20} />
  232. </IconContainer>
  233. <Label>
  234. <Highlight text={inputValue}>{display}</Highlight>
  235. {suggestedReason && <SuggestedReason>({suggestedReason})</SuggestedReason>}
  236. </Label>
  237. </MenuItemWrapper>
  238. ),
  239. };
  240. }
  241. renderNewTeamNodes(): ItemsBeforeFilter {
  242. return this.assignableTeams().map(team => this.renderTeamNode(team));
  243. }
  244. renderSuggestedAssigneeNodes(): React.ComponentProps<
  245. typeof DropdownAutoComplete
  246. >['items'] {
  247. const {assignedTo} = this.state;
  248. const textReason: Record<SuggestedOwnerReason, string> = {
  249. suspectCommit: t('Suspect Commit'),
  250. releaseCommit: t('Suspect Release'),
  251. ownershipRule: t('Ownership Rule'),
  252. codeowners: t('Codeowners'),
  253. };
  254. // filter out suggested assignees if a suggestion is already selected
  255. return this.getSuggestedAssignees()
  256. .filter(({type, id}) => !(type === assignedTo?.type && id === assignedTo?.id))
  257. .filter(({type}) => type === 'user' || type === 'team')
  258. .map(({type, suggestedReason, assignee}) => {
  259. const reason = textReason[suggestedReason];
  260. if (type === 'user') {
  261. return this.renderMemberNode(assignee as User, reason);
  262. }
  263. return this.renderTeamNode(assignee as AssignableTeam, reason);
  264. });
  265. }
  266. renderDropdownGroupLabel(label: string) {
  267. return <GroupHeader>{label}</GroupHeader>;
  268. }
  269. renderNewDropdownItems(): ItemsBeforeFilter {
  270. const teams = this.renderNewTeamNodes();
  271. const members = this.renderNewMemberNodes();
  272. const sessionUser = ConfigStore.get('user');
  273. const suggestedAssignees = this.renderSuggestedAssigneeNodes() ?? [];
  274. const filteredSessionUser: ItemsBeforeFilter = members.filter(
  275. member => member.value.assignee.id === sessionUser.id
  276. );
  277. // filter out session user from Suggested
  278. const filteredSuggestedAssignees: ItemsBeforeFilter = suggestedAssignees.filter(
  279. assignee => {
  280. return assignee.value.type === 'member'
  281. ? assignee.value.assignee.id !== sessionUser.id
  282. : assignee;
  283. }
  284. );
  285. const assigneeIds = new Set(
  286. filteredSuggestedAssignees.map(
  287. assignee => `${assignee.value.type}:${assignee.value.assignee.id}`
  288. )
  289. );
  290. // filter out duplicates of Team/Member if also a Suggested Assignee
  291. const filteredTeams: ItemsBeforeFilter = teams.filter(team => {
  292. return !assigneeIds.has(`${team.value.type}:${team.value.assignee.id}`);
  293. });
  294. const filteredMembers: ItemsBeforeFilter = members.filter(member => {
  295. return (
  296. !assigneeIds.has(`${member.value.type}:${member.value.assignee.id}`) &&
  297. member.value.assignee.id !== sessionUser.id
  298. );
  299. });
  300. const dropdownItems: ItemsBeforeFilter = [
  301. {
  302. label: this.renderDropdownGroupLabel(t('Teams')),
  303. id: 'team-header',
  304. items: filteredTeams,
  305. },
  306. {
  307. label: this.renderDropdownGroupLabel(t('People')),
  308. id: 'members-header',
  309. items: filteredMembers,
  310. },
  311. ];
  312. // session user is first on dropdown
  313. if (suggestedAssignees.length || filteredSessionUser.length) {
  314. dropdownItems.unshift(
  315. {
  316. label: this.renderDropdownGroupLabel(t('Suggested')),
  317. id: 'suggested-header',
  318. items: filteredSessionUser,
  319. },
  320. {
  321. hideGroupLabel: true,
  322. id: 'suggested-list',
  323. items: filteredSuggestedAssignees,
  324. }
  325. );
  326. }
  327. return dropdownItems;
  328. }
  329. renderInviteMemberLink() {
  330. const {loading} = this.state;
  331. return (
  332. <InviteMemberLink
  333. to=""
  334. data-test-id="invite-member"
  335. disabled={loading}
  336. onClick={() => openInviteMembersModal({source: 'assignee_selector'})}
  337. >
  338. <MenuItemFooterWrapper>
  339. <IconContainer>
  340. <IconAdd color="purple300" isCircled size="14px" />
  341. </IconContainer>
  342. <Label>{t('Invite Member')}</Label>
  343. </MenuItemFooterWrapper>
  344. </InviteMemberLink>
  345. );
  346. }
  347. getSuggestedAssignees(): SuggestedAssignee[] {
  348. const {suggestedOwners} = this.state;
  349. if (!suggestedOwners) {
  350. return [];
  351. }
  352. const assignableTeams = this.assignableTeams();
  353. const memberList = this.memberList() ?? [];
  354. const suggestedAssignees: Array<SuggestedAssignee | null> = suggestedOwners.map(
  355. owner => {
  356. // converts a backend suggested owner to a suggested assignee
  357. const [ownerType, id] = owner.owner.split(':');
  358. if (ownerType === 'user') {
  359. const member = memberList.find(user => user.id === id);
  360. if (member) {
  361. return {
  362. type: 'user',
  363. id,
  364. name: member.name,
  365. suggestedReason: owner.type,
  366. assignee: member,
  367. };
  368. }
  369. } else if (ownerType === 'team') {
  370. const matchingTeam = assignableTeams.find(
  371. assignableTeam => assignableTeam.id === owner.owner
  372. );
  373. if (matchingTeam) {
  374. return {
  375. type: 'team',
  376. id,
  377. name: matchingTeam.team.name,
  378. suggestedReason: owner.type,
  379. assignee: matchingTeam,
  380. };
  381. }
  382. }
  383. return null;
  384. }
  385. );
  386. return suggestedAssignees.filter(owner => !!owner) as SuggestedAssignee[];
  387. }
  388. render() {
  389. const {disabled, children} = this.props;
  390. const {loading, assignedTo} = this.state;
  391. const memberList = this.memberList();
  392. return (
  393. <DropdownAutoComplete
  394. disabled={disabled}
  395. maxHeight={400}
  396. onOpen={e => {
  397. // This can be called multiple times and does not always have `event`
  398. e?.stopPropagation();
  399. }}
  400. busy={memberList === undefined}
  401. items={memberList !== undefined ? this.renderNewDropdownItems() : null}
  402. alignMenu="right"
  403. onSelect={this.handleAssign}
  404. itemSize="small"
  405. searchPlaceholder={t('Filter teams and people')}
  406. menuFooter={
  407. assignedTo ? (
  408. <div>
  409. <MenuItemFooterWrapper role="button" onClick={this.clearAssignTo} py={0}>
  410. <IconContainer>
  411. <IconClose color="purple300" isCircled size="14px" />
  412. </IconContainer>
  413. <Label>{t('Clear Assignee')}</Label>
  414. </MenuItemFooterWrapper>
  415. {this.renderInviteMemberLink()}
  416. </div>
  417. ) : (
  418. this.renderInviteMemberLink()
  419. )
  420. }
  421. disableLabelPadding
  422. emptyHidesInput
  423. >
  424. {({getActorProps, isOpen}) =>
  425. children({
  426. loading,
  427. isOpen,
  428. getActorProps,
  429. assignedTo,
  430. suggestedAssignees: this.getSuggestedAssignees(),
  431. })
  432. }
  433. </DropdownAutoComplete>
  434. );
  435. }
  436. }
  437. export function putSessionUserFirst(members: User[] | undefined): User[] {
  438. // If session user is in the filtered list of members, put them at the top
  439. if (!members) {
  440. return [];
  441. }
  442. const sessionUser = ConfigStore.get('user');
  443. const sessionUserIndex = members.findIndex(member => member.id === sessionUser?.id);
  444. if (sessionUserIndex === -1) {
  445. return members;
  446. }
  447. const arrangedMembers = [members[sessionUserIndex]];
  448. arrangedMembers.push(...members.slice(0, sessionUserIndex));
  449. arrangedMembers.push(...members.slice(sessionUserIndex + 1));
  450. return arrangedMembers;
  451. }
  452. const IconContainer = styled('div')`
  453. display: flex;
  454. align-items: center;
  455. justify-content: center;
  456. width: 24px;
  457. height: 24px;
  458. flex-shrink: 0;
  459. `;
  460. const MenuItemWrapper = styled('div')<{
  461. disabled?: boolean;
  462. py?: number;
  463. }>`
  464. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  465. display: flex;
  466. align-items: center;
  467. font-size: 13px;
  468. ${p =>
  469. typeof p.py !== 'undefined' &&
  470. `
  471. padding-top: ${p.py};
  472. padding-bottom: ${p.py};
  473. `};
  474. `;
  475. const MenuItemFooterWrapper = styled(MenuItemWrapper)`
  476. padding: ${space(0.25)} ${space(1)};
  477. border-top: 1px solid ${p => p.theme.innerBorder};
  478. background-color: ${p => p.theme.tag.highlight.background};
  479. color: ${p => p.theme.active};
  480. :hover {
  481. color: ${p => p.theme.activeHover};
  482. svg {
  483. fill: ${p => p.theme.activeHover};
  484. }
  485. }
  486. `;
  487. const InviteMemberLink = styled(Link)`
  488. color: ${p => (p.disabled ? p.theme.disabled : p.theme.textColor)};
  489. `;
  490. const Label = styled(TextOverflow)`
  491. margin-left: 6px;
  492. `;
  493. const GroupHeader = styled('div')`
  494. font-size: ${p => p.theme.fontSizeSmall};
  495. font-weight: 600;
  496. margin: ${space(1)} 0;
  497. color: ${p => p.theme.subText};
  498. line-height: ${p => p.theme.fontSizeSmall};
  499. text-align: left;
  500. `;
  501. const SuggestedReason = styled('span')`
  502. margin-left: ${space(0.5)};
  503. color: ${p => p.theme.textColor};
  504. `;