assigneeSelector.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. import * as 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, assignedBy: 'assignee_selector'});
  149. this.setState({loading: true});
  150. }
  151. assignToTeam(team: Team) {
  152. assignToActor({
  153. actor: {id: team.id, type: 'team'},
  154. id: this.props.id,
  155. assignedBy: 'assignee_selector',
  156. });
  157. this.setState({loading: true});
  158. }
  159. handleAssign: React.ComponentProps<typeof DropdownAutoComplete>['onSelect'] = (
  160. {value: {type, assignee}},
  161. _state,
  162. e
  163. ) => {
  164. if (type === 'member') {
  165. this.assignToUser(assignee);
  166. }
  167. if (type === 'team') {
  168. this.assignToTeam(assignee);
  169. }
  170. e?.stopPropagation();
  171. const {onAssign} = this.props;
  172. if (onAssign) {
  173. const suggestionType = type === 'member' ? 'user' : type;
  174. const suggestion = this.getSuggestedAssignees().find(
  175. actor => actor.type === suggestionType && actor.id === assignee.id
  176. );
  177. onAssign?.(type, assignee, suggestion);
  178. }
  179. };
  180. clearAssignTo = (e: React.MouseEvent<HTMLDivElement>) => {
  181. // clears assignment
  182. clearAssignment(this.props.id, 'assignee_selector');
  183. this.setState({loading: true});
  184. e.stopPropagation();
  185. };
  186. renderMemberNode(member: User, suggestedReason?: string): ItemsBeforeFilter[0] {
  187. const {size} = this.props;
  188. return {
  189. value: {type: 'member', assignee: member},
  190. searchKey: `${member.email} ${member.name}`,
  191. label: ({inputValue}) => (
  192. <MenuItemWrapper
  193. data-test-id="assignee-option"
  194. key={buildUserId(member.id)}
  195. onSelect={this.assignToUser.bind(this, member)}
  196. >
  197. <IconContainer>
  198. <UserAvatar user={member} size={size} />
  199. </IconContainer>
  200. <Label>
  201. <Highlight text={inputValue}>{member.name || member.email}</Highlight>
  202. {suggestedReason && <SuggestedReason>{suggestedReason}</SuggestedReason>}
  203. </Label>
  204. </MenuItemWrapper>
  205. ),
  206. };
  207. }
  208. renderNewMemberNodes(): ItemsBeforeFilter {
  209. const members = putSessionUserFirst(this.memberList());
  210. return members.map(member => this.renderMemberNode(member));
  211. }
  212. renderTeamNode(
  213. assignableTeam: AssignableTeam,
  214. suggestedReason?: string
  215. ): ItemsBeforeFilter[0] {
  216. const {size} = this.props;
  217. const {id, display, team} = assignableTeam;
  218. return {
  219. value: {type: 'team', assignee: team},
  220. searchKey: team.slug,
  221. label: ({inputValue}) => (
  222. <MenuItemWrapper
  223. data-test-id="assignee-option"
  224. key={id}
  225. onSelect={this.assignToTeam.bind(this, team)}
  226. >
  227. <IconContainer>
  228. <TeamAvatar team={team} size={size} />
  229. </IconContainer>
  230. <Label>
  231. <Highlight text={inputValue}>{display}</Highlight>
  232. {suggestedReason && <SuggestedReason>{suggestedReason}</SuggestedReason>}
  233. </Label>
  234. </MenuItemWrapper>
  235. ),
  236. };
  237. }
  238. renderNewTeamNodes(): ItemsBeforeFilter {
  239. return this.assignableTeams().map(team => this.renderTeamNode(team));
  240. }
  241. renderSuggestedAssigneeNodes(): React.ComponentProps<
  242. typeof DropdownAutoComplete
  243. >['items'] {
  244. const {assignedTo} = this.state;
  245. // filter out suggested assignees if a suggestion is already selected
  246. return this.getSuggestedAssignees()
  247. .filter(({type, id}) => !(type === assignedTo?.type && id === assignedTo?.id))
  248. .filter(({type}) => type === 'user' || type === 'team')
  249. .map(({type, suggestedReason, assignee}) => {
  250. const reason =
  251. suggestedReason === 'suspectCommit'
  252. ? t('(Suspect Commit)')
  253. : t('(Issue Owner)');
  254. if (type === 'user') {
  255. return this.renderMemberNode(assignee as User, reason);
  256. }
  257. return this.renderTeamNode(assignee as AssignableTeam, reason);
  258. });
  259. }
  260. renderDropdownGroupLabel(label: string) {
  261. return <GroupHeader>{label}</GroupHeader>;
  262. }
  263. renderNewDropdownItems(): ItemsBeforeFilter {
  264. const teams = this.renderNewTeamNodes();
  265. const members = this.renderNewMemberNodes();
  266. const suggestedAssignees = this.renderSuggestedAssigneeNodes() ?? [];
  267. const assigneeIds = new Set(
  268. suggestedAssignees.map(
  269. assignee => `${assignee.value.type}:${assignee.value.assignee.id}`
  270. )
  271. );
  272. // filter out duplicates of Team/Member if also a Suggested Assignee
  273. const filteredTeams: ItemsBeforeFilter = teams.filter(team => {
  274. return !assigneeIds.has(`${team.value.type}:${team.value.assignee.id}`);
  275. });
  276. const filteredMembers: ItemsBeforeFilter = members.filter(member => {
  277. return !assigneeIds.has(`${member.value.type}:${member.value.assignee.id}`);
  278. });
  279. const dropdownItems: ItemsBeforeFilter = [
  280. {
  281. label: this.renderDropdownGroupLabel(t('Teams')),
  282. id: 'team-header',
  283. items: filteredTeams,
  284. },
  285. {
  286. label: this.renderDropdownGroupLabel(t('People')),
  287. id: 'members-header',
  288. items: filteredMembers,
  289. },
  290. ];
  291. if (suggestedAssignees.length) {
  292. dropdownItems.unshift({
  293. label: this.renderDropdownGroupLabel(t('Suggested')),
  294. id: 'suggested-header',
  295. items: suggestedAssignees,
  296. });
  297. }
  298. return dropdownItems;
  299. }
  300. getSuggestedAssignees(): SuggestedAssignee[] {
  301. const {suggestedOwners} = this.state;
  302. if (!suggestedOwners) {
  303. return [];
  304. }
  305. const assignableTeams = this.assignableTeams();
  306. const memberList = this.memberList() ?? [];
  307. const suggestedAssignees: Array<SuggestedAssignee | null> = suggestedOwners.map(
  308. owner => {
  309. // converts a backend suggested owner to a suggested assignee
  310. const [ownerType, id] = owner.owner.split(':');
  311. if (ownerType === 'user') {
  312. const member = memberList.find(user => user.id === id);
  313. if (member) {
  314. return {
  315. type: 'user',
  316. id,
  317. name: member.name,
  318. suggestedReason: owner.type,
  319. assignee: member,
  320. };
  321. }
  322. } else if (ownerType === 'team') {
  323. const matchingTeam = assignableTeams.find(
  324. assignableTeam => assignableTeam.id === owner.owner
  325. );
  326. if (matchingTeam) {
  327. return {
  328. type: 'team',
  329. id,
  330. name: matchingTeam.team.name,
  331. suggestedReason: owner.type,
  332. assignee: matchingTeam,
  333. };
  334. }
  335. }
  336. return null;
  337. }
  338. );
  339. return suggestedAssignees.filter(owner => !!owner) as SuggestedAssignee[];
  340. }
  341. render() {
  342. const {disabled} = this.props;
  343. const {loading, assignedTo} = this.state;
  344. const memberList = this.memberList();
  345. const suggestedActors = this.getSuggestedAssignees();
  346. const suggestedReasons: Record<SuggestedOwnerReason, React.ReactNode> = {
  347. suspectCommit: tct('Based on [commit:commit data]', {
  348. commit: (
  349. <TooltipSubExternalLink href="https://docs.sentry.io/product/sentry-basics/guides/integrate-frontend/configure-scms/" />
  350. ),
  351. }),
  352. ownershipRule: t('Matching Issue Owners Rule'),
  353. };
  354. const assignedToSuggestion = suggestedActors.find(
  355. actor => actor.id === assignedTo?.id
  356. );
  357. return (
  358. <AssigneeWrapper>
  359. {loading && (
  360. <LoadingIndicator mini style={{height: '24px', margin: 0, marginRight: 11}} />
  361. )}
  362. {!loading && (
  363. <DropdownAutoComplete
  364. disabled={disabled}
  365. maxHeight={400}
  366. onOpen={e => {
  367. // This can be called multiple times and does not always have `event`
  368. e?.stopPropagation();
  369. }}
  370. busy={memberList === undefined}
  371. items={memberList !== undefined ? this.renderNewDropdownItems() : null}
  372. alignMenu="right"
  373. onSelect={this.handleAssign}
  374. itemSize="small"
  375. searchPlaceholder={t('Filter teams and people')}
  376. menuHeader={
  377. assignedTo && (
  378. <MenuItemWrapper
  379. data-test-id="clear-assignee"
  380. onClick={this.clearAssignTo}
  381. py={0}
  382. >
  383. <IconContainer>
  384. <ClearAssigneeIcon isCircled size="14px" />
  385. </IconContainer>
  386. <Label>{t('Clear Assignee')}</Label>
  387. </MenuItemWrapper>
  388. )
  389. }
  390. menuFooter={
  391. <InviteMemberLink
  392. to=""
  393. data-test-id="invite-member"
  394. disabled={loading}
  395. onClick={() => openInviteMembersModal({source: 'assignee_selector'})}
  396. >
  397. <MenuItemWrapper>
  398. <IconContainer>
  399. <InviteMemberIcon isCircled size="14px" />
  400. </IconContainer>
  401. <Label>{t('Invite Member')}</Label>
  402. </MenuItemWrapper>
  403. </InviteMemberLink>
  404. }
  405. menuWithArrow
  406. emptyHidesInput
  407. >
  408. {({getActorProps, isOpen}) => (
  409. <DropdownButton {...getActorProps({})}>
  410. {assignedTo ? (
  411. <ActorAvatar
  412. actor={assignedTo}
  413. className="avatar"
  414. size={24}
  415. tooltip={
  416. <TooltipWrapper>
  417. {tct('Assigned to [name]', {
  418. name:
  419. assignedTo.type === 'team'
  420. ? `#${assignedTo.name}`
  421. : assignedTo.name,
  422. })}
  423. {assignedToSuggestion && (
  424. <TooltipSubtext>
  425. {suggestedReasons[assignedToSuggestion.suggestedReason]}
  426. </TooltipSubtext>
  427. )}
  428. </TooltipWrapper>
  429. }
  430. />
  431. ) : suggestedActors && suggestedActors.length > 0 ? (
  432. <SuggestedAvatarStack
  433. size={24}
  434. owners={suggestedActors}
  435. tooltipOptions={{isHoverable: true}}
  436. tooltip={
  437. <TooltipWrapper>
  438. <div>
  439. {tct('Suggestion: [name]', {
  440. name:
  441. suggestedActors[0].type === 'team'
  442. ? `#${suggestedActors[0].name}`
  443. : suggestedActors[0].name,
  444. })}
  445. {suggestedActors.length > 1 &&
  446. tn(' + %s other', ' + %s others', suggestedActors.length - 1)}
  447. </div>
  448. <TooltipSubtext>
  449. {suggestedReasons[suggestedActors[0].suggestedReason]}
  450. </TooltipSubtext>
  451. </TooltipWrapper>
  452. }
  453. />
  454. ) : (
  455. <Tooltip
  456. isHoverable
  457. skipWrapper
  458. title={
  459. <TooltipWrapper>
  460. <div>{t('Unassigned')}</div>
  461. <TooltipSubtext>
  462. {tct(
  463. 'You can auto-assign issues by adding [issueOwners:Issue Owner rules].',
  464. {
  465. issueOwners: (
  466. <TooltipSubExternalLink href="https://docs.sentry.io/product/error-monitoring/issue-owners/" />
  467. ),
  468. }
  469. )}
  470. </TooltipSubtext>
  471. </TooltipWrapper>
  472. }
  473. >
  474. <StyledIconUser size="20px" color="gray400" />
  475. </Tooltip>
  476. )}
  477. <StyledChevron direction={isOpen ? 'up' : 'down'} size="xs" />
  478. </DropdownButton>
  479. )}
  480. </DropdownAutoComplete>
  481. )}
  482. </AssigneeWrapper>
  483. );
  484. }
  485. }
  486. export function putSessionUserFirst(members: User[] | undefined): User[] {
  487. // If session user is in the filtered list of members, put them at the top
  488. if (!members) {
  489. return [];
  490. }
  491. const sessionUser = ConfigStore.get('user');
  492. const sessionUserIndex = members.findIndex(member => member.id === sessionUser?.id);
  493. if (sessionUserIndex === -1) {
  494. return members;
  495. }
  496. const arrangedMembers = [members[sessionUserIndex]];
  497. arrangedMembers.push(...members.slice(0, sessionUserIndex));
  498. arrangedMembers.push(...members.slice(sessionUserIndex + 1));
  499. return arrangedMembers;
  500. }
  501. export default AssigneeSelector;
  502. const AssigneeWrapper = styled('div')`
  503. display: flex;
  504. justify-content: flex-end;
  505. /* manually align menu underneath dropdown caret */
  506. ${DropdownBubble} {
  507. right: -14px;
  508. }
  509. `;
  510. const StyledIconUser = styled(IconUser)`
  511. /* We need this to center with Avatar */
  512. margin-right: 2px;
  513. `;
  514. const IconContainer = styled('div')`
  515. display: flex;
  516. align-items: center;
  517. justify-content: center;
  518. width: 24px;
  519. height: 24px;
  520. flex-shrink: 0;
  521. `;
  522. const MenuItemWrapper = styled('div')<{
  523. py?: number;
  524. disabled?: boolean;
  525. }>`
  526. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  527. display: flex;
  528. align-items: center;
  529. font-size: 13px;
  530. ${p =>
  531. typeof p.py !== 'undefined' &&
  532. `
  533. padding-top: ${p.py};
  534. padding-bottom: ${p.py};
  535. `};
  536. `;
  537. const InviteMemberLink = styled(Link)`
  538. color: ${p => (p.disabled ? p.theme.disabled : p.theme.textColor)};
  539. `;
  540. const Label = styled(TextOverflow)`
  541. margin-left: 6px;
  542. `;
  543. const ClearAssigneeIcon = styled(IconClose)`
  544. opacity: 0.3;
  545. `;
  546. const InviteMemberIcon = styled(IconAdd)`
  547. opacity: 0.3;
  548. `;
  549. const StyledChevron = styled(IconChevron)`
  550. margin-left: ${space(1)};
  551. `;
  552. const DropdownButton = styled('div')`
  553. display: flex;
  554. align-items: center;
  555. font-size: 20px;
  556. `;
  557. const GroupHeader = styled('div')`
  558. font-size: ${p => p.theme.fontSizeSmall};
  559. font-weight: 600;
  560. margin: ${space(1)} 0;
  561. color: ${p => p.theme.subText};
  562. line-height: ${p => p.theme.fontSizeSmall};
  563. text-align: left;
  564. `;
  565. const SuggestedReason = styled('span')`
  566. margin-left: ${space(0.5)};
  567. color: ${p => p.theme.textColor};
  568. `;
  569. const TooltipWrapper = styled('div')`
  570. text-align: left;
  571. `;
  572. const TooltipSubtext = styled('div')`
  573. color: ${p => p.theme.subText};
  574. `;
  575. const TooltipSubExternalLink = styled(ExternalLink)`
  576. color: ${p => p.theme.subText};
  577. text-decoration: underline;
  578. :hover {
  579. color: ${p => p.theme.subText};
  580. }
  581. `;