assigneeSelectorDropdown.tsx 20 KB

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