assigneeSelectorDropdown.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  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. const {organization} = this.props;
  165. assignToUser({
  166. id: this.props.id,
  167. orgSlug: organization.slug,
  168. user,
  169. assignedBy: 'assignee_selector',
  170. });
  171. this.setState({loading: true});
  172. }
  173. assignToTeam(team: Team) {
  174. const {organization} = this.props;
  175. assignToActor({
  176. actor: {id: team.id, type: 'team'},
  177. id: this.props.id,
  178. orgSlug: organization.slug,
  179. assignedBy: 'assignee_selector',
  180. });
  181. this.setState({loading: true});
  182. }
  183. handleAssign: React.ComponentProps<typeof DropdownAutoComplete>['onSelect'] = (
  184. {value: {type, assignee}},
  185. _state,
  186. e
  187. ) => {
  188. if (type === 'member') {
  189. this.assignToUser(assignee);
  190. }
  191. if (type === 'team') {
  192. this.assignToTeam(assignee);
  193. }
  194. e?.stopPropagation();
  195. const {onAssign} = this.props;
  196. if (onAssign) {
  197. const suggestionType = type === 'member' ? 'user' : type;
  198. const suggestion = this.getSuggestedAssignees().find(
  199. actor => actor.type === suggestionType && actor.id === assignee.id
  200. );
  201. onAssign(type, assignee, suggestion);
  202. }
  203. };
  204. clearAssignTo = (e: React.MouseEvent<HTMLDivElement>) => {
  205. const {organization} = this.props;
  206. // clears assignment
  207. clearAssignment(this.props.id, organization.slug, 'assignee_selector');
  208. this.setState({loading: true});
  209. e.stopPropagation();
  210. };
  211. renderMemberNode(
  212. member: User,
  213. suggestedReason?: React.ReactNode
  214. ): ItemsBeforeFilter[0] {
  215. const sessionUser = ConfigStore.get('user');
  216. const handleSelect = () => this.assignToUser(member);
  217. return {
  218. value: {type: 'member', assignee: member},
  219. searchKey: `${member.email} ${member.name}`,
  220. label: ({inputValue}) => (
  221. <MenuItemWrapper
  222. data-test-id="assignee-option"
  223. key={buildUserId(member.id)}
  224. onSelect={handleSelect}
  225. >
  226. <IconContainer>
  227. <UserAvatar user={member} size={24} />
  228. </IconContainer>
  229. <div>
  230. <AssigneeLabel>
  231. <Highlight text={inputValue}>
  232. {sessionUser.id === member.id
  233. ? `${member.name || member.email} ${t('(You)')}`
  234. : member.name || member.email}
  235. </Highlight>
  236. </AssigneeLabel>
  237. {suggestedReason && (
  238. <SuggestedAssigneeReason>{suggestedReason}</SuggestedAssigneeReason>
  239. )}
  240. </div>
  241. </MenuItemWrapper>
  242. ),
  243. };
  244. }
  245. renderNewMemberNodes(): ItemsBeforeFilter {
  246. const members = putSessionUserFirst(this.memberList());
  247. return members.map(member => this.renderMemberNode(member));
  248. }
  249. renderTeamNode(
  250. assignableTeam: AssignableTeam,
  251. suggestedReason?: React.ReactNode
  252. ): ItemsBeforeFilter[0] {
  253. const {id, display, team} = assignableTeam;
  254. const handleSelect = () => this.assignToTeam(team);
  255. return {
  256. value: {type: 'team', assignee: team},
  257. searchKey: team.slug,
  258. label: ({inputValue}) => (
  259. <MenuItemWrapper data-test-id="assignee-option" key={id} onSelect={handleSelect}>
  260. <IconContainer>
  261. <TeamAvatar team={team} size={24} />
  262. </IconContainer>
  263. <div>
  264. <AssigneeLabel>
  265. <Highlight text={inputValue}>{display}</Highlight>
  266. </AssigneeLabel>
  267. {suggestedReason && (
  268. <SuggestedAssigneeReason>{suggestedReason}</SuggestedAssigneeReason>
  269. )}
  270. </div>
  271. </MenuItemWrapper>
  272. ),
  273. };
  274. }
  275. renderSuggestedAssigneeNodes(): React.ComponentProps<
  276. typeof DropdownAutoComplete
  277. >['items'] {
  278. const {assignedTo} = this.props;
  279. // filter out suggested assignees if a suggestion is already selected
  280. return this.getSuggestedAssignees()
  281. .filter(({type, id}) => !(type === assignedTo?.type && id === assignedTo?.id))
  282. .filter(({type}) => type === 'user' || type === 'team')
  283. .map(({type, suggestedReasonText, assignee}) => {
  284. if (type === 'user') {
  285. return this.renderMemberNode(assignee as User, suggestedReasonText);
  286. }
  287. return this.renderTeamNode(assignee as AssignableTeam, suggestedReasonText);
  288. });
  289. }
  290. renderDropdownGroupLabel(label: string) {
  291. return <GroupHeader>{label}</GroupHeader>;
  292. }
  293. renderNewDropdownItems(): ItemsBeforeFilter {
  294. const teams = this.assignableTeams().map(team => this.renderTeamNode(team));
  295. const members = this.renderNewMemberNodes();
  296. const sessionUser = ConfigStore.get('user');
  297. const suggestedAssignees = this.renderSuggestedAssigneeNodes() ?? [];
  298. const filteredSessionUser: ItemsBeforeFilter = members.filter(
  299. member => member.value.assignee.id === sessionUser.id
  300. );
  301. const assigneeIds = new Set(
  302. suggestedAssignees.map(
  303. assignee => `${assignee.value.type}:${assignee.value.assignee.id}`
  304. )
  305. );
  306. // filter out duplicates of Team/Member if also a Suggested Assignee
  307. const filteredTeams: ItemsBeforeFilter = teams.filter(team => {
  308. return !assigneeIds.has(`${team.value.type}:${team.value.assignee.id}`);
  309. });
  310. const filteredMembers: ItemsBeforeFilter = members.filter(member => {
  311. return (
  312. !assigneeIds.has(`${member.value.type}:${member.value.assignee.id}`) &&
  313. member.value.assignee.id !== sessionUser.id
  314. );
  315. });
  316. // New version combines teams and users into one section
  317. const dropdownItems: ItemsBeforeFilter = [
  318. {
  319. label: this.renderDropdownGroupLabel(t('Everyone Else')),
  320. hideGroupLabel: !suggestedAssignees.length,
  321. id: 'everyone-else',
  322. items: [...filteredSessionUser, ...filteredTeams, ...filteredMembers],
  323. },
  324. ];
  325. if (suggestedAssignees.length) {
  326. // Add suggested assingees
  327. dropdownItems.unshift({
  328. label: this.renderDropdownGroupLabel(t('Suggested Assignees')),
  329. id: 'suggested-list',
  330. items: suggestedAssignees,
  331. });
  332. }
  333. return dropdownItems;
  334. }
  335. renderInviteMemberLink() {
  336. const {loading} = this.state;
  337. return (
  338. <InviteMemberLink
  339. to="#invite-member"
  340. data-test-id="invite-member"
  341. disabled={loading}
  342. onClick={event => {
  343. event.preventDefault();
  344. openInviteMembersModal({source: 'assignee_selector'});
  345. }}
  346. >
  347. <MenuItemFooterWrapper>
  348. <IconContainer>
  349. <IconAdd color="activeText" isCircled legacySize="14px" />
  350. </IconContainer>
  351. <Label>{t('Invite Member')}</Label>
  352. </MenuItemFooterWrapper>
  353. </InviteMemberLink>
  354. );
  355. }
  356. getSuggestedAssignees(): SuggestedAssignee[] {
  357. const assignableTeams = this.assignableTeams();
  358. const memberList = this.memberList() ?? [];
  359. const {owners} = this.props;
  360. if (owners !== undefined) {
  361. // Add team or user from store
  362. return owners
  363. .map<SuggestedAssignee | null>(owner => {
  364. if (owner.type === 'user') {
  365. const member = memberList.find(user => user.id === owner.id);
  366. if (member) {
  367. return {
  368. ...owner,
  369. assignee: member,
  370. };
  371. }
  372. }
  373. if (owner.type === 'team') {
  374. const matchingTeam = assignableTeams.find(
  375. assignableTeam => assignableTeam.team.id === owner.id
  376. );
  377. if (matchingTeam) {
  378. return {
  379. ...owner,
  380. assignee: matchingTeam,
  381. };
  382. }
  383. }
  384. return null;
  385. })
  386. .filter((owner): owner is SuggestedAssignee => !!owner);
  387. }
  388. const {suggestedOwners} = this.state;
  389. if (!suggestedOwners) {
  390. return [];
  391. }
  392. const textReason: Record<SuggestedOwnerReason, string> = {
  393. suspectCommit: t('Suspect Commit'),
  394. ownershipRule: t('Ownership Rule'),
  395. projectOwnership: t('Ownership Rule'),
  396. // TODO: codeowners may no longer exist
  397. codeowners: t('Codeowners'),
  398. };
  399. const uniqueSuggestions = uniqBy(suggestedOwners, owner => owner.owner);
  400. return uniqueSuggestions
  401. .map<SuggestedAssignee | null>(owner => {
  402. // converts a backend suggested owner to a suggested assignee
  403. const [ownerType, id] = owner.owner.split(':');
  404. const suggestedReasonText = textReason[owner.type];
  405. if (ownerType === 'user') {
  406. const member = memberList.find(user => user.id === id);
  407. if (member) {
  408. return {
  409. id,
  410. type: 'user',
  411. name: member.name,
  412. suggestedReason: owner.type,
  413. suggestedReasonText,
  414. assignee: member,
  415. };
  416. }
  417. } else if (ownerType === 'team') {
  418. const matchingTeam = assignableTeams.find(
  419. assignableTeam => assignableTeam.id === owner.owner
  420. );
  421. if (matchingTeam) {
  422. return {
  423. id,
  424. type: 'team',
  425. name: matchingTeam.team.name,
  426. suggestedReason: owner.type,
  427. suggestedReasonText,
  428. assignee: matchingTeam,
  429. };
  430. }
  431. }
  432. return null;
  433. })
  434. .filter((owner): owner is SuggestedAssignee => !!owner);
  435. }
  436. render() {
  437. const {disabled, children, assignedTo} = this.props;
  438. const {loading} = this.state;
  439. const memberList = this.memberList();
  440. const suggestedAssignees = this.getSuggestedAssignees();
  441. return (
  442. <DropdownAutoComplete
  443. disabled={disabled}
  444. maxHeight={400}
  445. onOpen={e => {
  446. // This can be called multiple times and does not always have `event`
  447. e?.stopPropagation();
  448. }}
  449. busy={memberList === undefined}
  450. items={memberList !== undefined ? this.renderNewDropdownItems() : null}
  451. alignMenu="right"
  452. onSelect={this.handleAssign}
  453. itemSize="small"
  454. searchPlaceholder={t('Filter teams and people')}
  455. menuFooter={
  456. assignedTo ? (
  457. <div>
  458. <MenuItemFooterWrapper role="button" onClick={this.clearAssignTo}>
  459. <IconContainer>
  460. <IconClose color="activeText" isCircled legacySize="14px" />
  461. </IconContainer>
  462. <Label>{t('Clear Assignee')}</Label>
  463. </MenuItemFooterWrapper>
  464. {this.renderInviteMemberLink()}
  465. </div>
  466. ) : (
  467. this.renderInviteMemberLink()
  468. )
  469. }
  470. disableLabelPadding
  471. emptyHidesInput
  472. >
  473. {({getActorProps, isOpen}) =>
  474. children({
  475. loading,
  476. isOpen,
  477. getActorProps,
  478. suggestedAssignees,
  479. })
  480. }
  481. </DropdownAutoComplete>
  482. );
  483. }
  484. }
  485. export function putSessionUserFirst(members: User[] | undefined): User[] {
  486. // If session user is in the filtered list of members, put them at the top
  487. if (!members) {
  488. return [];
  489. }
  490. const sessionUser = ConfigStore.get('user');
  491. const sessionUserIndex = members.findIndex(member => member.id === sessionUser?.id);
  492. if (sessionUserIndex === -1) {
  493. return members;
  494. }
  495. const arrangedMembers = [members[sessionUserIndex]];
  496. arrangedMembers.push(...members.slice(0, sessionUserIndex));
  497. arrangedMembers.push(...members.slice(sessionUserIndex + 1));
  498. return arrangedMembers;
  499. }
  500. const IconContainer = styled('div')`
  501. display: flex;
  502. align-items: center;
  503. justify-content: center;
  504. width: 24px;
  505. height: 24px;
  506. flex-shrink: 0;
  507. `;
  508. const MenuItemWrapper = styled('div')<{
  509. disabled?: boolean;
  510. py?: number;
  511. }>`
  512. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  513. display: flex;
  514. align-items: center;
  515. font-size: 13px;
  516. padding: ${space(0.5)} ${space(0.5)};
  517. ${p =>
  518. typeof p.py !== 'undefined' &&
  519. `
  520. padding-top: ${p.py};
  521. padding-bottom: ${p.py};
  522. `};
  523. `;
  524. const MenuItemFooterWrapper = styled('div')`
  525. display: flex;
  526. align-items: center;
  527. padding: ${space(0.25)} ${space(1)};
  528. border-top: 1px solid ${p => p.theme.innerBorder};
  529. background-color: ${p => p.theme.tag.highlight.background};
  530. color: ${p => p.theme.activeText};
  531. :hover {
  532. color: ${p => p.theme.activeHover};
  533. svg {
  534. fill: ${p => p.theme.activeHover};
  535. }
  536. }
  537. `;
  538. const InviteMemberLink = styled(Link)`
  539. color: ${p => (p.disabled ? p.theme.disabled : p.theme.textColor)};
  540. `;
  541. const Label = styled(TextOverflow)`
  542. margin-left: 6px;
  543. `;
  544. const AssigneeLabel = styled('div')`
  545. ${p => p.theme.overflowEllipsis}
  546. margin-left: ${space(1)};
  547. max-width: 300px;
  548. `;
  549. const SuggestedAssigneeReason = styled(AssigneeLabel)`
  550. color: ${p => p.theme.subText};
  551. font-size: ${p => p.theme.fontSizeSmall};
  552. `;
  553. const GroupHeader = styled('div')`
  554. font-size: 75%;
  555. line-height: 1.5;
  556. font-weight: 600;
  557. text-transform: uppercase;
  558. margin: ${space(1)} 0;
  559. color: ${p => p.theme.subText};
  560. text-align: left;
  561. `;