cellAction.tsx 8.3 KB

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