assigneeSelectorDropdown.tsx 18 KB

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