cellAction.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import {Component} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button} from 'sentry/components/button';
  4. import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu';
  5. import {IconEllipsis} from 'sentry/icons';
  6. import {t, tct} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import {defined} from 'sentry/utils';
  9. import {TableDataRow} from 'sentry/utils/discover/discoverQuery';
  10. import {
  11. isEquationAlias,
  12. isRelativeSpanOperationBreakdownField,
  13. } from 'sentry/utils/discover/fields';
  14. import {getDuration} from 'sentry/utils/formatters';
  15. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  16. import {TableColumn} from './types';
  17. export enum Actions {
  18. ADD = 'add',
  19. EXCLUDE = 'exclude',
  20. SHOW_GREATER_THAN = 'show_greater_than',
  21. SHOW_LESS_THAN = 'show_less_than',
  22. RELEASE = 'release',
  23. DRILLDOWN = 'drilldown',
  24. EDIT_THRESHOLD = 'edit_threshold',
  25. }
  26. export function updateQuery(
  27. results: MutableSearch,
  28. action: Actions,
  29. column: TableColumn<keyof TableDataRow>,
  30. value: React.ReactText | string[]
  31. ) {
  32. const key = column.name;
  33. if (column.type === 'duration' && typeof value === 'number') {
  34. // values are assumed to be in milliseconds
  35. value = getDuration(value / 1000, 2, true);
  36. }
  37. // De-duplicate array values
  38. if (Array.isArray(value)) {
  39. value = [...new Set(value)];
  40. if (value.length === 1) {
  41. value = value[0];
  42. }
  43. }
  44. switch (action) {
  45. case Actions.ADD:
  46. // If the value is null/undefined create a has !has condition.
  47. if (value === null || value === undefined) {
  48. // Adding a null value is the same as excluding truthy values.
  49. // Remove inclusion if it exists.
  50. results.removeFilterValue('has', key);
  51. results.addFilterValues('!has', [key]);
  52. } else {
  53. addToFilter(results, key, value);
  54. }
  55. break;
  56. case Actions.EXCLUDE:
  57. if (value === null || value === undefined) {
  58. // Excluding a null value is the same as including truthy values.
  59. // Remove exclusion if it exists.
  60. results.removeFilterValue('!has', key);
  61. results.addFilterValues('has', [key]);
  62. } else {
  63. excludeFromFilter(results, key, value);
  64. }
  65. break;
  66. case Actions.SHOW_GREATER_THAN: {
  67. // Remove query token if it already exists
  68. results.setFilterValues(key, [`>${value}`]);
  69. break;
  70. }
  71. case Actions.SHOW_LESS_THAN: {
  72. // Remove query token if it already exists
  73. results.setFilterValues(key, [`<${value}`]);
  74. break;
  75. }
  76. // these actions do not modify the query in any way,
  77. // instead they have side effects
  78. case Actions.RELEASE:
  79. case Actions.DRILLDOWN:
  80. break;
  81. default:
  82. throw new Error(`Unknown action type. ${action}`);
  83. }
  84. }
  85. export function addToFilter(
  86. oldFilter: MutableSearch,
  87. key: string,
  88. value: React.ReactText | string[]
  89. ) {
  90. // Remove exclusion if it exists.
  91. oldFilter.removeFilter(`!${key}`);
  92. if (Array.isArray(value)) {
  93. // For array values, add to existing filters
  94. const currentFilters = oldFilter.getFilterValues(key);
  95. value = [...new Set([...currentFilters, ...value])];
  96. } else {
  97. value = [String(value)];
  98. }
  99. oldFilter.setFilterValues(key, value);
  100. }
  101. export function excludeFromFilter(
  102. oldFilter: MutableSearch,
  103. key: string,
  104. value: React.ReactText | string[]
  105. ) {
  106. // Remove positive if it exists.
  107. oldFilter.removeFilter(key);
  108. // Negations should stack up.
  109. const negation = `!${key}`;
  110. value = Array.isArray(value) ? value : [String(value)];
  111. const currentNegations = oldFilter.getFilterValues(negation);
  112. oldFilter.removeFilter(negation);
  113. // We shouldn't escape any of the existing conditions since the
  114. // existing conditions have already been set an verified by the user
  115. oldFilter.addFilterValues(
  116. negation,
  117. currentNegations.filter(filterValue => !(value as string[]).includes(filterValue)),
  118. false
  119. );
  120. // Escapes the new condition if necessary
  121. oldFilter.addFilterValues(negation, value);
  122. }
  123. type CellActionsOpts = {
  124. column: TableColumn<keyof TableDataRow>;
  125. dataRow: TableDataRow;
  126. handleCellAction: (action: Actions, value: React.ReactText) => void;
  127. /**
  128. * allow list of actions to display on the context menu
  129. */
  130. allowActions?: Actions[];
  131. children?: React.ReactNode;
  132. };
  133. function makeCellActions({
  134. dataRow,
  135. column,
  136. handleCellAction,
  137. allowActions,
  138. }: CellActionsOpts) {
  139. // Do not render context menu buttons for the span op breakdown field.
  140. if (isRelativeSpanOperationBreakdownField(column.name)) {
  141. return null;
  142. }
  143. // Do not render context menu buttons for the equation fields until we can query on them
  144. if (isEquationAlias(column.name)) {
  145. return null;
  146. }
  147. let value = dataRow[column.name];
  148. // error.handled is a strange field where null = true.
  149. if (
  150. Array.isArray(value) &&
  151. value[0] === null &&
  152. column.column.kind === 'field' &&
  153. column.column.field === 'error.handled'
  154. ) {
  155. value = 1;
  156. }
  157. const actions: MenuItemProps[] = [];
  158. function addMenuItem(
  159. action: Actions,
  160. itemLabel: React.ReactNode,
  161. itemTextValue?: string
  162. ) {
  163. if ((Array.isArray(allowActions) && allowActions.includes(action)) || !allowActions) {
  164. actions.push({
  165. key: action,
  166. label: itemLabel,
  167. textValue: itemTextValue,
  168. onAction: () => handleCellAction(action, value),
  169. });
  170. }
  171. }
  172. if (
  173. !['duration', 'number', 'percentage'].includes(column.type) ||
  174. (value === null && column.column.kind === 'field')
  175. ) {
  176. addMenuItem(Actions.ADD, t('Add to filter'));
  177. if (column.type !== 'date') {
  178. addMenuItem(Actions.EXCLUDE, t('Exclude from filter'));
  179. }
  180. }
  181. if (
  182. ['date', 'duration', 'integer', 'number', 'percentage'].includes(column.type) &&
  183. value !== null
  184. ) {
  185. addMenuItem(Actions.SHOW_GREATER_THAN, t('Show values greater than'));
  186. addMenuItem(Actions.SHOW_LESS_THAN, t('Show values less than'));
  187. }
  188. if (column.column.kind === 'field' && column.column.field === 'release' && value) {
  189. addMenuItem(Actions.RELEASE, t('Go to release'));
  190. }
  191. if (column.column.kind === 'function' && column.column.function[0] === 'count_unique') {
  192. addMenuItem(Actions.DRILLDOWN, t('View Stacks'));
  193. }
  194. if (
  195. column.column.kind === 'function' &&
  196. column.column.function[0] === 'user_misery' &&
  197. defined(dataRow.project_threshold_config)
  198. ) {
  199. addMenuItem(
  200. Actions.EDIT_THRESHOLD,
  201. tct('Edit threshold ([threshold]ms)', {
  202. threshold: dataRow.project_threshold_config[1],
  203. }),
  204. t('Edit threshold')
  205. );
  206. }
  207. if (actions.length === 0) {
  208. return null;
  209. }
  210. return actions;
  211. }
  212. type Props = React.PropsWithoutRef<CellActionsOpts>;
  213. type State = {
  214. isHovering: boolean;
  215. isOpen: boolean;
  216. };
  217. class CellAction extends Component<Props, State> {
  218. render() {
  219. const {children} = this.props;
  220. const cellActions = makeCellActions(this.props);
  221. return (
  222. <Container data-test-id="cell-action-container">
  223. {children}
  224. {cellActions?.length && (
  225. <DropdownMenu
  226. items={cellActions}
  227. usePortal
  228. size="sm"
  229. offset={4}
  230. position="bottom"
  231. preventOverflowOptions={{padding: 4}}
  232. flipOptions={{
  233. fallbackPlacements: [
  234. 'top',
  235. 'right-start',
  236. 'right-end',
  237. 'left-start',
  238. 'left-end',
  239. ],
  240. }}
  241. trigger={triggerProps => (
  242. <ActionMenuTrigger
  243. {...triggerProps}
  244. translucentBorder
  245. aria-label={t('Actions')}
  246. icon={<IconEllipsis size="xs" />}
  247. size="zero"
  248. />
  249. )}
  250. />
  251. )}
  252. </Container>
  253. );
  254. }
  255. }
  256. export default CellAction;
  257. const Container = styled('div')`
  258. position: relative;
  259. width: 100%;
  260. height: 100%;
  261. display: flex;
  262. flex-direction: column;
  263. justify-content: center;
  264. `;
  265. const ActionMenuTrigger = styled(Button)`
  266. position: absolute;
  267. top: 50%;
  268. right: -1px;
  269. transform: translateY(-50%);
  270. padding: ${space(0.5)};
  271. display: flex;
  272. align-items: center;
  273. opacity: 0;
  274. transition: opacity 0.1s;
  275. &.focus-visible,
  276. &[aria-expanded='true'],
  277. ${Container}:hover & {
  278. opacity: 1;
  279. }
  280. `;