assigneeSelector.tsx 19 KB

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