teamKeyTransaction.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. import {Component, Fragment} from 'react';
  2. import ReactDOM from 'react-dom';
  3. import {Manager, Popper, Reference} from 'react-popper';
  4. import styled from '@emotion/styled';
  5. import * as PopperJS from 'popper.js';
  6. import MenuHeader from 'sentry/components/actions/menuHeader';
  7. import CheckboxFancy from 'sentry/components/checkboxFancy/checkboxFancy';
  8. import {GetActorPropsFn} from 'sentry/components/dropdownMenu';
  9. import MenuItem from 'sentry/components/menuItem';
  10. import {TeamSelection} from 'sentry/components/performance/teamKeyTransactionsManager';
  11. import {t} from 'sentry/locale';
  12. import space from 'sentry/styles/space';
  13. import {Project, Team} from 'sentry/types';
  14. import {defined} from 'sentry/utils';
  15. import {MAX_TEAM_KEY_TRANSACTIONS} from 'sentry/utils/performance/constants';
  16. export type TitleProps = Partial<ReturnType<GetActorPropsFn>> & {
  17. isOpen: boolean;
  18. keyedTeams: Team[] | null;
  19. disabled?: boolean;
  20. initialValue?: number;
  21. };
  22. type Props = {
  23. counts: Map<string, number> | null;
  24. error: string | null;
  25. handleToggleKeyTransaction: (selection: TeamSelection) => void;
  26. isLoading: boolean;
  27. keyedTeams: Set<string> | null;
  28. project: Project;
  29. teams: Team[];
  30. title: React.ComponentClass<TitleProps>;
  31. transactionName: string;
  32. initialValue?: number;
  33. };
  34. type State = {
  35. isOpen: boolean;
  36. };
  37. class TeamKeyTransaction extends Component<Props, State> {
  38. constructor(props: Props) {
  39. super(props);
  40. let portal = document.getElementById('team-key-transaction-portal');
  41. if (!portal) {
  42. portal = document.createElement('div');
  43. portal.setAttribute('id', 'team-key-transaction-portal');
  44. document.body.appendChild(portal);
  45. }
  46. this.portalEl = portal;
  47. this.menuEl = null;
  48. }
  49. state: State = {
  50. isOpen: false,
  51. };
  52. componentDidUpdate(_props: Props, prevState: State) {
  53. if (this.state.isOpen && prevState.isOpen === false) {
  54. document.addEventListener('click', this.handleClickOutside, true);
  55. }
  56. if (this.state.isOpen === false && prevState.isOpen) {
  57. document.removeEventListener('click', this.handleClickOutside, true);
  58. }
  59. }
  60. componentWillUnmount() {
  61. document.removeEventListener('click', this.handleClickOutside, true);
  62. this.portalEl.remove();
  63. }
  64. private portalEl: Element;
  65. private menuEl: Element | null;
  66. handleClickOutside = (event: MouseEvent) => {
  67. if (!this.menuEl) {
  68. return;
  69. }
  70. if (!(event.target instanceof Element)) {
  71. return;
  72. }
  73. if (this.menuEl.contains(event.target)) {
  74. return;
  75. }
  76. this.setState({isOpen: false});
  77. };
  78. toggleOpen = () => {
  79. this.setState(({isOpen}) => ({isOpen: !isOpen}));
  80. };
  81. toggleSelection = (enabled: boolean, selection: TeamSelection) => () => {
  82. const {handleToggleKeyTransaction} = this.props;
  83. return enabled ? handleToggleKeyTransaction(selection) : undefined;
  84. };
  85. partitionTeams(counts: Map<string, number>, keyedTeams: Set<string>) {
  86. const {teams, project} = this.props;
  87. const enabledTeams: Team[] = [];
  88. const disabledTeams: Team[] = [];
  89. const noAccessTeams: Team[] = [];
  90. const projectTeams = new Set(project.teams.map(({id}) => id));
  91. for (const team of teams) {
  92. if (!projectTeams.has(team.id)) {
  93. noAccessTeams.push(team);
  94. } else if (
  95. keyedTeams.has(team.id) ||
  96. (counts.get(team.id) ?? 0) < MAX_TEAM_KEY_TRANSACTIONS
  97. ) {
  98. enabledTeams.push(team);
  99. } else {
  100. disabledTeams.push(team);
  101. }
  102. }
  103. return {
  104. enabledTeams,
  105. disabledTeams,
  106. noAccessTeams,
  107. };
  108. }
  109. renderMenuContent(counts: Map<string, number>, keyedTeams: Set<string>) {
  110. const {teams, project, transactionName} = this.props;
  111. const {enabledTeams, disabledTeams, noAccessTeams} = this.partitionTeams(
  112. counts,
  113. keyedTeams
  114. );
  115. const isMyTeamsEnabled = enabledTeams.length > 0;
  116. const myTeamsHandler = this.toggleSelection(isMyTeamsEnabled, {
  117. action: enabledTeams.length === keyedTeams.size ? 'unkey' : 'key',
  118. teamIds: enabledTeams.map(({id}) => id),
  119. project,
  120. transactionName,
  121. });
  122. const hasTeamsWithAccess = enabledTeams.length + disabledTeams.length > 0;
  123. return (
  124. <DropdownContent>
  125. {hasTeamsWithAccess && (
  126. <Fragment>
  127. <DropdownMenuHeader first>
  128. {t('My Teams with Access')}
  129. <ActionItem>
  130. <CheckboxFancy
  131. isDisabled={!isMyTeamsEnabled}
  132. isChecked={teams.length === keyedTeams.size}
  133. isIndeterminate={teams.length > keyedTeams.size && keyedTeams.size > 0}
  134. onClick={myTeamsHandler}
  135. />
  136. </ActionItem>
  137. </DropdownMenuHeader>
  138. {enabledTeams.map(team => (
  139. <TeamKeyTransactionItem
  140. key={team.slug}
  141. team={team}
  142. isKeyed={keyedTeams.has(team.id)}
  143. disabled={false}
  144. onSelect={this.toggleSelection(true, {
  145. action: keyedTeams.has(team.id) ? 'unkey' : 'key',
  146. teamIds: [team.id],
  147. project,
  148. transactionName,
  149. })}
  150. />
  151. ))}
  152. {disabledTeams.map(team => (
  153. <TeamKeyTransactionItem
  154. key={team.slug}
  155. team={team}
  156. isKeyed={keyedTeams.has(team.id)}
  157. disabled
  158. onSelect={this.toggleSelection(true, {
  159. action: keyedTeams.has(team.id) ? 'unkey' : 'key',
  160. teamIds: [team.id],
  161. project,
  162. transactionName,
  163. })}
  164. />
  165. ))}
  166. </Fragment>
  167. )}
  168. {noAccessTeams.length > 0 && (
  169. <Fragment>
  170. <DropdownMenuHeader first={!hasTeamsWithAccess}>
  171. {t('My Teams without Access')}
  172. </DropdownMenuHeader>
  173. {noAccessTeams.map(team => (
  174. <TeamKeyTransactionItem key={team.slug} team={team} disabled />
  175. ))}
  176. </Fragment>
  177. )}
  178. </DropdownContent>
  179. );
  180. }
  181. renderMenu(): React.ReactPortal | null {
  182. const {isLoading, counts, keyedTeams} = this.props;
  183. if (isLoading || !defined(counts) || !defined(keyedTeams)) {
  184. return null;
  185. }
  186. const modifiers: PopperJS.Modifiers = {
  187. hide: {
  188. enabled: false,
  189. },
  190. preventOverflow: {
  191. padding: 10,
  192. enabled: true,
  193. boundariesElement: 'viewport',
  194. },
  195. };
  196. return ReactDOM.createPortal(
  197. <Popper placement="top" modifiers={modifiers}>
  198. {({ref: popperRef, style, placement}) => (
  199. <DropdownWrapper
  200. ref={ref => {
  201. (popperRef as Function)(ref);
  202. this.menuEl = ref;
  203. }}
  204. style={style}
  205. data-placement={placement}
  206. >
  207. {this.renderMenuContent(counts, keyedTeams)}
  208. </DropdownWrapper>
  209. )}
  210. </Popper>,
  211. this.portalEl
  212. );
  213. }
  214. render() {
  215. const {isLoading, error, title: Title, keyedTeams, initialValue, teams} = this.props;
  216. const {isOpen} = this.state;
  217. const menu: React.ReactPortal | null = isOpen ? this.renderMenu() : null;
  218. return (
  219. <Manager>
  220. <Reference>
  221. {({ref}) => (
  222. <StarWrapper ref={ref}>
  223. <Title
  224. isOpen={isOpen}
  225. disabled={isLoading || Boolean(error)}
  226. keyedTeams={
  227. keyedTeams ? teams.filter(({id}) => keyedTeams.has(id)) : null
  228. }
  229. initialValue={initialValue}
  230. onClick={this.toggleOpen}
  231. />
  232. </StarWrapper>
  233. )}
  234. </Reference>
  235. {menu}
  236. </Manager>
  237. );
  238. }
  239. }
  240. type ItemProps = {
  241. disabled: boolean;
  242. team: Team;
  243. isKeyed?: boolean;
  244. onSelect?: () => void;
  245. };
  246. function TeamKeyTransactionItem({team, isKeyed, disabled, onSelect}: ItemProps) {
  247. return (
  248. <DropdownMenuItem
  249. key={team.slug}
  250. disabled={disabled}
  251. onSelect={onSelect}
  252. stopPropagation
  253. >
  254. <MenuItemContent>
  255. {team.slug}
  256. <ActionItem>
  257. {!defined(isKeyed) ? null : disabled ? (
  258. t('Max %s', MAX_TEAM_KEY_TRANSACTIONS)
  259. ) : (
  260. <CheckboxFancy isChecked={isKeyed} />
  261. )}
  262. </ActionItem>
  263. </MenuItemContent>
  264. </DropdownMenuItem>
  265. );
  266. }
  267. const StarWrapper = styled('div')`
  268. display: flex;
  269. /* Fixes Star when it’s filled and is wrapped around Tooltip */
  270. & > span {
  271. display: flex;
  272. }
  273. `;
  274. const DropdownWrapper = styled('div')`
  275. /* Adapted from the dropdown-menu class */
  276. border: none;
  277. border-radius: 2px;
  278. box-shadow: 0 0 0 1px rgba(52, 60, 69, 0.2), 0 1px 3px rgba(70, 82, 98, 0.25);
  279. background-clip: padding-box;
  280. background-color: ${p => p.theme.background};
  281. width: 220px;
  282. overflow: visible;
  283. z-index: ${p => p.theme.zIndex.tooltip};
  284. &:before,
  285. &:after {
  286. width: 0;
  287. height: 0;
  288. content: '';
  289. display: block;
  290. position: absolute;
  291. right: auto;
  292. }
  293. &:before {
  294. border-left: 9px solid transparent;
  295. border-right: 9px solid transparent;
  296. left: calc(50% - 9px);
  297. z-index: -2;
  298. }
  299. &:after {
  300. border-left: 8px solid transparent;
  301. border-right: 8px solid transparent;
  302. left: calc(50% - 8px);
  303. z-index: -1;
  304. }
  305. &[data-placement*='bottom'] {
  306. margin-top: 9px;
  307. &:before {
  308. border-bottom: 9px solid ${p => p.theme.border};
  309. top: -9px;
  310. }
  311. &:after {
  312. border-bottom: 8px solid ${p => p.theme.background};
  313. top: -8px;
  314. }
  315. }
  316. &[data-placement*='top'] {
  317. margin-bottom: 9px;
  318. &:before {
  319. border-top: 9px solid ${p => p.theme.border};
  320. bottom: -9px;
  321. }
  322. &:after {
  323. border-top: 8px solid ${p => p.theme.background};
  324. bottom: -8px;
  325. }
  326. }
  327. `;
  328. const DropdownContent = styled('div')`
  329. max-height: 250px;
  330. pointer-events: auto;
  331. overflow-y: auto;
  332. `;
  333. const DropdownMenuHeader = styled(MenuHeader)<{first?: boolean}>`
  334. display: flex;
  335. flex-direction: row;
  336. justify-content: space-between;
  337. align-items: center;
  338. padding: ${space(1)} ${space(2)};
  339. background: ${p => p.theme.backgroundSecondary};
  340. ${p => p.first && 'border-radius: 2px'};
  341. `;
  342. const DropdownMenuItem = styled(MenuItem)`
  343. font-size: ${p => p.theme.fontSizeMedium};
  344. &:not(:last-child) {
  345. border-bottom: 1px solid ${p => p.theme.innerBorder};
  346. }
  347. `;
  348. const MenuItemContent = styled('div')`
  349. display: flex;
  350. flex-direction: row;
  351. justify-content: space-between;
  352. align-items: center;
  353. width: 100%;
  354. `;
  355. const ActionItem = styled('span')`
  356. min-width: ${space(2)};
  357. margin-left: ${space(1)};
  358. `;
  359. export default TeamKeyTransaction;