cellAction.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. import {Component} from 'react';
  2. import {createPortal} from 'react-dom';
  3. import {Manager, Popper, Reference} from 'react-popper';
  4. import styled from '@emotion/styled';
  5. import color from 'color';
  6. import {IconEllipsis} from 'sentry/icons';
  7. import {t, tct} from 'sentry/locale';
  8. import space from 'sentry/styles/space';
  9. import {defined} from 'sentry/utils';
  10. import {TableDataRow} from 'sentry/utils/discover/discoverQuery';
  11. import {
  12. isEquationAlias,
  13. isRelativeSpanOperationBreakdownField,
  14. } from 'sentry/utils/discover/fields';
  15. import {getDuration} from 'sentry/utils/formatters';
  16. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  17. import {TableColumn} from './types';
  18. export enum Actions {
  19. ADD = 'add',
  20. EXCLUDE = 'exclude',
  21. SHOW_GREATER_THAN = 'show_greater_than',
  22. SHOW_LESS_THAN = 'show_less_than',
  23. TRANSACTION = 'transaction',
  24. RELEASE = 'release',
  25. DRILLDOWN = 'drilldown',
  26. EDIT_THRESHOLD = 'edit_threshold',
  27. }
  28. export function updateQuery(
  29. results: MutableSearch,
  30. action: Actions,
  31. column: TableColumn<keyof TableDataRow>,
  32. value: React.ReactText | string[]
  33. ) {
  34. const key = column.name;
  35. if (column.type === 'duration' && typeof value === 'number') {
  36. // values are assumed to be in milliseconds
  37. value = getDuration(value / 1000, 2, true);
  38. }
  39. // De-duplicate array values
  40. if (Array.isArray(value)) {
  41. value = [...new Set(value)];
  42. if (value.length === 1) {
  43. value = value[0];
  44. }
  45. }
  46. switch (action) {
  47. case Actions.ADD:
  48. // If the value is null/undefined create a has !has condition.
  49. if (value === null || value === undefined) {
  50. // Adding a null value is the same as excluding truthy values.
  51. // Remove inclusion if it exists.
  52. results.removeFilterValue('has', key);
  53. results.addFilterValues('!has', [key]);
  54. } else {
  55. // Remove exclusion if it exists.
  56. results.removeFilter(`!${key}`);
  57. if (Array.isArray(value)) {
  58. // For array values, add to existing filters
  59. const currentFilters = results.getFilterValues(key);
  60. value = [...new Set([...currentFilters, ...value])];
  61. } else {
  62. value = [String(value)];
  63. }
  64. results.setFilterValues(key, value);
  65. }
  66. break;
  67. case Actions.EXCLUDE:
  68. if (value === null || value === undefined) {
  69. // Excluding a null value is the same as including truthy values.
  70. // Remove exclusion if it exists.
  71. results.removeFilterValue('!has', key);
  72. results.addFilterValues('has', [key]);
  73. } else {
  74. // Remove positive if it exists.
  75. results.removeFilter(key);
  76. // Negations should stack up.
  77. const negation = `!${key}`;
  78. value = Array.isArray(value) ? value : [String(value)];
  79. const currentNegations = results.getFilterValues(negation);
  80. value = [...new Set([...currentNegations, ...value])];
  81. results.setFilterValues(negation, value);
  82. }
  83. break;
  84. case Actions.SHOW_GREATER_THAN: {
  85. // Remove query token if it already exists
  86. results.setFilterValues(key, [`>${value}`]);
  87. break;
  88. }
  89. case Actions.SHOW_LESS_THAN: {
  90. // Remove query token if it already exists
  91. results.setFilterValues(key, [`<${value}`]);
  92. break;
  93. }
  94. // these actions do not modify the query in any way,
  95. // instead they have side effects
  96. case Actions.TRANSACTION:
  97. case Actions.RELEASE:
  98. case Actions.DRILLDOWN:
  99. break;
  100. default:
  101. throw new Error(`Unknown action type. ${action}`);
  102. }
  103. }
  104. type CellActionsOpts = {
  105. column: TableColumn<keyof TableDataRow>;
  106. dataRow: TableDataRow;
  107. handleCellAction: (action: Actions, value: React.ReactText) => void;
  108. /**
  109. * allow list of actions to display on the context menu
  110. */
  111. allowActions?: Actions[];
  112. };
  113. function makeCellActions({
  114. dataRow,
  115. column,
  116. handleCellAction,
  117. allowActions,
  118. }: CellActionsOpts) {
  119. // Do not render context menu buttons for the span op breakdown field.
  120. if (isRelativeSpanOperationBreakdownField(column.name)) {
  121. return null;
  122. }
  123. // Do not render context menu buttons for the equation fields until we can query on them
  124. if (isEquationAlias(column.name)) {
  125. return null;
  126. }
  127. let value = dataRow[column.name];
  128. // error.handled is a strange field where null = true.
  129. if (
  130. Array.isArray(value) &&
  131. value[0] === null &&
  132. column.column.kind === 'field' &&
  133. column.column.field === 'error.handled'
  134. ) {
  135. value = 1;
  136. }
  137. const actions: React.ReactNode[] = [];
  138. function addMenuItem(action: Actions, menuItem: React.ReactNode) {
  139. if ((Array.isArray(allowActions) && allowActions.includes(action)) || !allowActions) {
  140. actions.push(menuItem);
  141. }
  142. }
  143. if (
  144. !['duration', 'number', 'percentage'].includes(column.type) ||
  145. (value === null && column.column.kind === 'field')
  146. ) {
  147. addMenuItem(
  148. Actions.ADD,
  149. <ActionItem
  150. key="add-to-filter"
  151. data-test-id="add-to-filter"
  152. onClick={() => handleCellAction(Actions.ADD, value)}
  153. >
  154. {t('Add to filter')}
  155. </ActionItem>
  156. );
  157. if (column.type !== 'date') {
  158. addMenuItem(
  159. Actions.EXCLUDE,
  160. <ActionItem
  161. key="exclude-from-filter"
  162. data-test-id="exclude-from-filter"
  163. onClick={() => handleCellAction(Actions.EXCLUDE, value)}
  164. >
  165. {t('Exclude from filter')}
  166. </ActionItem>
  167. );
  168. }
  169. }
  170. if (
  171. ['date', 'duration', 'integer', 'number', 'percentage'].includes(column.type) &&
  172. value !== null
  173. ) {
  174. addMenuItem(
  175. Actions.SHOW_GREATER_THAN,
  176. <ActionItem
  177. key="show-values-greater-than"
  178. data-test-id="show-values-greater-than"
  179. onClick={() => handleCellAction(Actions.SHOW_GREATER_THAN, value)}
  180. >
  181. {t('Show values greater than')}
  182. </ActionItem>
  183. );
  184. addMenuItem(
  185. Actions.SHOW_LESS_THAN,
  186. <ActionItem
  187. key="show-values-less-than"
  188. data-test-id="show-values-less-than"
  189. onClick={() => handleCellAction(Actions.SHOW_LESS_THAN, value)}
  190. >
  191. {t('Show values less than')}
  192. </ActionItem>
  193. );
  194. }
  195. if (column.column.kind === 'field' && column.column.field === 'transaction') {
  196. addMenuItem(
  197. Actions.TRANSACTION,
  198. <ActionItem
  199. key="transaction-summary"
  200. data-test-id="transaction-summary"
  201. onClick={() => handleCellAction(Actions.TRANSACTION, value)}
  202. >
  203. {t('Go to summary')}
  204. </ActionItem>
  205. );
  206. }
  207. if (column.column.kind === 'field' && column.column.field === 'release' && value) {
  208. addMenuItem(
  209. Actions.RELEASE,
  210. <ActionItem
  211. key="release"
  212. data-test-id="release"
  213. onClick={() => handleCellAction(Actions.RELEASE, value)}
  214. >
  215. {t('Go to release')}
  216. </ActionItem>
  217. );
  218. }
  219. if (column.column.kind === 'function' && column.column.function[0] === 'count_unique') {
  220. addMenuItem(
  221. Actions.DRILLDOWN,
  222. <ActionItem
  223. key="drilldown"
  224. data-test-id="per-cell-drilldown"
  225. onClick={() => handleCellAction(Actions.DRILLDOWN, value)}
  226. >
  227. {t('View Stacks')}
  228. </ActionItem>
  229. );
  230. }
  231. if (
  232. column.column.kind === 'function' &&
  233. column.column.function[0] === 'user_misery' &&
  234. defined(dataRow.project_threshold_config)
  235. ) {
  236. addMenuItem(
  237. Actions.EDIT_THRESHOLD,
  238. <ActionItem
  239. key="edit_threshold"
  240. data-test-id="edit-threshold"
  241. onClick={() => handleCellAction(Actions.EDIT_THRESHOLD, value)}
  242. >
  243. {tct('Edit threshold ([threshold]ms)', {
  244. threshold: dataRow.project_threshold_config[1],
  245. })}
  246. </ActionItem>
  247. );
  248. }
  249. if (actions.length === 0) {
  250. return null;
  251. }
  252. return actions;
  253. }
  254. type Props = React.PropsWithoutRef<CellActionsOpts>;
  255. type State = {
  256. isHovering: boolean;
  257. isOpen: boolean;
  258. };
  259. class CellAction extends Component<Props, State> {
  260. constructor(props: Props) {
  261. super(props);
  262. let portal = document.getElementById('cell-action-portal');
  263. if (!portal) {
  264. portal = document.createElement('div');
  265. portal.setAttribute('id', 'cell-action-portal');
  266. document.body.appendChild(portal);
  267. }
  268. this.portalEl = portal;
  269. this.menuEl = null;
  270. }
  271. state: State = {
  272. isHovering: false,
  273. isOpen: false,
  274. };
  275. componentDidUpdate(_props: Props, prevState: State) {
  276. if (this.state.isOpen && prevState.isOpen === false) {
  277. document.addEventListener('click', this.handleClickOutside, true);
  278. }
  279. if (this.state.isOpen === false && prevState.isOpen) {
  280. document.removeEventListener('click', this.handleClickOutside, true);
  281. }
  282. }
  283. componentWillUnmount() {
  284. document.removeEventListener('click', this.handleClickOutside, true);
  285. }
  286. private portalEl: Element;
  287. private menuEl: Element | null;
  288. handleClickOutside = (event: MouseEvent) => {
  289. if (!this.menuEl) {
  290. return;
  291. }
  292. if (!(event.target instanceof Element)) {
  293. return;
  294. }
  295. if (this.menuEl.contains(event.target)) {
  296. return;
  297. }
  298. this.setState({isOpen: false, isHovering: false});
  299. };
  300. handleMouseEnter = () => {
  301. this.setState({isHovering: true});
  302. };
  303. handleMouseLeave = () => {
  304. this.setState(state => {
  305. // Don't hide the button if the menu is open.
  306. if (state.isOpen) {
  307. return state;
  308. }
  309. return {...state, isHovering: false};
  310. });
  311. };
  312. handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
  313. event.preventDefault();
  314. this.setState({isOpen: !this.state.isOpen});
  315. };
  316. renderMenu() {
  317. const {isOpen} = this.state;
  318. const actions = makeCellActions(this.props);
  319. if (actions === null) {
  320. // do not render the menu if there are no per cell actions
  321. return null;
  322. }
  323. const modifiers = [
  324. {
  325. name: 'hide',
  326. enabled: false,
  327. },
  328. {
  329. name: 'preventOverflow',
  330. enabled: true,
  331. options: {
  332. padding: 10,
  333. altAxis: true,
  334. },
  335. },
  336. {
  337. name: 'offset',
  338. options: {
  339. offset: [0, ARROW_SIZE / 2],
  340. },
  341. },
  342. {
  343. name: 'computeStyles',
  344. options: {
  345. // Using the `transform` attribute causes our borders to get blurry
  346. // in chrome. See [0]. This just causes it to use `top` / `left`
  347. // positions, which should be fine.
  348. //
  349. // [0]: https://stackoverflow.com/questions/29543142/css3-transformation-blurry-borders
  350. gpuAcceleration: false,
  351. },
  352. },
  353. ];
  354. const menu = !isOpen
  355. ? null
  356. : createPortal(
  357. <Popper placement="top" modifiers={modifiers}>
  358. {({ref: popperRef, style, placement, arrowProps}) => (
  359. <Menu
  360. ref={ref => {
  361. (popperRef as Function)(ref);
  362. this.menuEl = ref;
  363. }}
  364. style={style}
  365. >
  366. <MenuArrow
  367. ref={arrowProps.ref}
  368. data-placement={placement}
  369. style={arrowProps.style}
  370. />
  371. <MenuButtons onClick={event => event.stopPropagation()}>
  372. {actions}
  373. </MenuButtons>
  374. </Menu>
  375. )}
  376. </Popper>,
  377. this.portalEl
  378. );
  379. return (
  380. <MenuRoot>
  381. <Manager>
  382. <Reference>
  383. {({ref}) => (
  384. <MenuButton ref={ref} onClick={this.handleMenuToggle}>
  385. <IconEllipsis size="sm" data-test-id="cell-action" color="blue300" />
  386. </MenuButton>
  387. )}
  388. </Reference>
  389. {menu}
  390. </Manager>
  391. </MenuRoot>
  392. );
  393. }
  394. render() {
  395. const {children} = this.props;
  396. const {isHovering} = this.state;
  397. return (
  398. <Container
  399. onMouseEnter={this.handleMouseEnter}
  400. onMouseLeave={this.handleMouseLeave}
  401. >
  402. {children}
  403. {isHovering && this.renderMenu()}
  404. </Container>
  405. );
  406. }
  407. }
  408. export default CellAction;
  409. const Container = styled('div')`
  410. position: relative;
  411. width: 100%;
  412. height: 100%;
  413. display: flex;
  414. flex-direction: column;
  415. justify-content: center;
  416. `;
  417. const MenuRoot = styled('div')`
  418. position: absolute;
  419. top: 0;
  420. right: 0;
  421. `;
  422. const Menu = styled('div')`
  423. z-index: ${p => p.theme.zIndex.tooltip};
  424. `;
  425. const MenuButtons = styled('div')`
  426. background: ${p => p.theme.background};
  427. border: 1px solid ${p => p.theme.border};
  428. border-radius: ${p => p.theme.borderRadius};
  429. box-shadow: ${p => p.theme.dropShadowHeavy};
  430. overflow: hidden;
  431. `;
  432. const ARROW_SIZE = 12;
  433. const MenuArrow = styled('span')`
  434. pointer-events: none;
  435. position: absolute;
  436. width: ${ARROW_SIZE}px;
  437. height: ${ARROW_SIZE}px;
  438. &::before,
  439. &::after {
  440. content: '';
  441. display: block;
  442. position: absolute;
  443. height: ${ARROW_SIZE}px;
  444. width: ${ARROW_SIZE}px;
  445. border: solid 6px transparent;
  446. }
  447. &[data-placement|='bottom'] {
  448. top: -${ARROW_SIZE}px;
  449. &::before {
  450. bottom: 1px;
  451. border-bottom-color: ${p => p.theme.translucentBorder};
  452. }
  453. &::after {
  454. border-bottom-color: ${p => p.theme.backgroundElevated};
  455. }
  456. }
  457. &[data-placement|='top'] {
  458. bottom: -${ARROW_SIZE}px;
  459. &::before {
  460. top: 1px;
  461. border-top-color: ${p => p.theme.translucentBorder};
  462. }
  463. &::after {
  464. border-top-color: ${p => p.theme.backgroundElevated};
  465. }
  466. }
  467. &[data-placement|='right'] {
  468. left: -${ARROW_SIZE}px;
  469. &::before {
  470. right: 1px;
  471. border-right-color: ${p => p.theme.translucentBorder};
  472. }
  473. &::after {
  474. border-right-color: ${p => p.theme.backgroundElevated};
  475. }
  476. }
  477. &[data-placement|='left'] {
  478. right: -${ARROW_SIZE}px;
  479. &::before {
  480. left: 1px;
  481. border-left-color: ${p => p.theme.translucentBorder};
  482. }
  483. &::after {
  484. border-left-color: ${p => p.theme.backgroundElevated};
  485. }
  486. }
  487. `;
  488. const ActionItem = styled('button')`
  489. display: block;
  490. width: 100%;
  491. padding: ${space(1)} ${space(2)};
  492. background: transparent;
  493. outline: none;
  494. border: 0;
  495. border-bottom: 1px solid ${p => p.theme.innerBorder};
  496. font-size: ${p => p.theme.fontSizeMedium};
  497. text-align: left;
  498. line-height: 1.2;
  499. &:hover {
  500. background: ${p => p.theme.backgroundSecondary};
  501. }
  502. &:last-child {
  503. border-bottom: 0;
  504. }
  505. `;
  506. const MenuButton = styled('button')`
  507. display: flex;
  508. width: 24px;
  509. height: 24px;
  510. padding: 0;
  511. justify-content: center;
  512. align-items: center;
  513. background: ${p => color(p.theme.background).alpha(0.85).string()};
  514. border-radius: ${p => p.theme.borderRadius};
  515. border: 1px solid ${p => p.theme.border};
  516. cursor: pointer;
  517. outline: none;
  518. `;