cellAction.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  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/duration/getDuration';
  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. // Negations should stack up.
  108. const negation = `!${key}`;
  109. value = Array.isArray(value) ? value : [String(value)];
  110. const currentNegations = oldFilter.getFilterValues(negation);
  111. oldFilter.removeFilter(negation);
  112. // We shouldn't escape any of the existing conditions since the
  113. // existing conditions have already been set an verified by the user
  114. oldFilter.addFilterValues(
  115. negation,
  116. currentNegations.filter(filterValue => !value.includes(filterValue)),
  117. false
  118. );
  119. // Escapes the new condition if necessary
  120. oldFilter.addFilterValues(negation, value);
  121. }
  122. type CellActionsOpts = {
  123. column: TableColumn<keyof TableDataRow>;
  124. dataRow: TableDataRow;
  125. handleCellAction: (action: Actions, value: React.ReactText) => void;
  126. /**
  127. * allow list of actions to display on the context menu
  128. */
  129. allowActions?: Actions[];
  130. children?: React.ReactNode;
  131. };
  132. function makeCellActions({
  133. dataRow,
  134. column,
  135. handleCellAction,
  136. allowActions,
  137. }: CellActionsOpts) {
  138. // Do not render context menu buttons for the span op breakdown field.
  139. if (isRelativeSpanOperationBreakdownField(column.name)) {
  140. return null;
  141. }
  142. // Do not render context menu buttons for the equation fields until we can query on them
  143. if (isEquationAlias(column.name)) {
  144. return null;
  145. }
  146. let value = dataRow[column.name];
  147. // error.handled is a strange field where null = true.
  148. if (
  149. Array.isArray(value) &&
  150. value[0] === null &&
  151. column.column.kind === 'field' &&
  152. column.column.field === 'error.handled'
  153. ) {
  154. value = 1;
  155. }
  156. const actions: MenuItemProps[] = [];
  157. function addMenuItem(
  158. action: Actions,
  159. itemLabel: React.ReactNode,
  160. itemTextValue?: string
  161. ) {
  162. if ((Array.isArray(allowActions) && allowActions.includes(action)) || !allowActions) {
  163. actions.push({
  164. key: action,
  165. label: itemLabel,
  166. textValue: itemTextValue,
  167. onAction: () => handleCellAction(action, value!),
  168. });
  169. }
  170. }
  171. if (
  172. !['duration', 'number', 'percentage'].includes(column.type) ||
  173. (value === null && column.column.kind === 'field')
  174. ) {
  175. addMenuItem(Actions.ADD, t('Add to filter'));
  176. if (column.type !== 'date') {
  177. addMenuItem(Actions.EXCLUDE, t('Exclude from filter'));
  178. }
  179. }
  180. if (
  181. ['date', 'duration', 'integer', 'number', 'percentage'].includes(column.type) &&
  182. value !== null
  183. ) {
  184. addMenuItem(Actions.SHOW_GREATER_THAN, t('Show values greater than'));
  185. addMenuItem(Actions.SHOW_LESS_THAN, t('Show values less than'));
  186. }
  187. if (column.column.kind === 'field' && column.column.field === 'release' && value) {
  188. addMenuItem(Actions.RELEASE, t('Go to release'));
  189. }
  190. if (column.column.kind === 'function' && column.column.function[0] === 'count_unique') {
  191. addMenuItem(Actions.DRILLDOWN, t('View Stacks'));
  192. }
  193. if (
  194. column.column.kind === 'function' &&
  195. column.column.function[0] === 'user_misery' &&
  196. defined(dataRow.project_threshold_config)
  197. ) {
  198. addMenuItem(
  199. Actions.EDIT_THRESHOLD,
  200. tct('Edit threshold ([threshold]ms)', {
  201. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  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
  223. data-test-id={cellActions === null ? undefined : 'cell-action-container'}
  224. >
  225. {children}
  226. {cellActions?.length && (
  227. <DropdownMenu
  228. items={cellActions}
  229. usePortal
  230. size="sm"
  231. offset={4}
  232. position="bottom"
  233. preventOverflowOptions={{padding: 4}}
  234. flipOptions={{
  235. fallbackPlacements: [
  236. 'top',
  237. 'right-start',
  238. 'right-end',
  239. 'left-start',
  240. 'left-end',
  241. ],
  242. }}
  243. trigger={triggerProps => (
  244. <ActionMenuTrigger
  245. {...triggerProps}
  246. translucentBorder
  247. aria-label={t('Actions')}
  248. icon={<IconEllipsis size="xs" />}
  249. size="zero"
  250. />
  251. )}
  252. />
  253. )}
  254. </Container>
  255. );
  256. }
  257. }
  258. export default CellAction;
  259. const Container = styled('div')`
  260. position: relative;
  261. width: 100%;
  262. height: 100%;
  263. display: flex;
  264. flex-direction: column;
  265. justify-content: center;
  266. `;
  267. const ActionMenuTrigger = styled(Button)`
  268. position: absolute;
  269. top: 50%;
  270. right: -1px;
  271. transform: translateY(-50%);
  272. padding: ${space(0.5)};
  273. display: flex;
  274. align-items: center;
  275. opacity: 0;
  276. transition: opacity 0.1s;
  277. &:focus-visible,
  278. &[aria-expanded='true'],
  279. ${Container}:hover & {
  280. opacity: 1;
  281. }
  282. `;