cellAction.tsx 15 KB

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