assigneeSelector.tsx 19 KB

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