import {Component} from 'react'; import styled from '@emotion/styled'; import {Button} from 'sentry/components/button'; import type {MenuItemProps} from 'sentry/components/dropdownMenu'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import {IconEllipsis} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {defined} from 'sentry/utils'; import type {TableDataRow} from 'sentry/utils/discover/discoverQuery'; import { isEquationAlias, isRelativeSpanOperationBreakdownField, } from 'sentry/utils/discover/fields'; import getDuration from 'sentry/utils/duration/getDuration'; import type {MutableSearch} from 'sentry/utils/tokenizeSearch'; import type {TableColumn} from './types'; export enum Actions { ADD = 'add', EXCLUDE = 'exclude', SHOW_GREATER_THAN = 'show_greater_than', SHOW_LESS_THAN = 'show_less_than', RELEASE = 'release', DRILLDOWN = 'drilldown', EDIT_THRESHOLD = 'edit_threshold', } export function updateQuery( results: MutableSearch, action: Actions, column: TableColumn, value: React.ReactText | string[] ) { const key = column.name; if (column.type === 'duration' && typeof value === 'number') { // values are assumed to be in milliseconds value = getDuration(value / 1000, 2, true); } // De-duplicate array values if (Array.isArray(value)) { value = [...new Set(value)]; if (value.length === 1) { value = value[0]; } } switch (action) { case Actions.ADD: // If the value is null/undefined create a has !has condition. if (value === null || value === undefined) { // Adding a null value is the same as excluding truthy values. // Remove inclusion if it exists. results.removeFilterValue('has', key); results.addFilterValues('!has', [key]); } else { addToFilter(results, key, value); } break; case Actions.EXCLUDE: if (value === null || value === undefined) { // Excluding a null value is the same as including truthy values. // Remove exclusion if it exists. results.removeFilterValue('!has', key); results.addFilterValues('has', [key]); } else { excludeFromFilter(results, key, value); } break; case Actions.SHOW_GREATER_THAN: { // Remove query token if it already exists results.setFilterValues(key, [`>${value}`]); break; } case Actions.SHOW_LESS_THAN: { // Remove query token if it already exists results.setFilterValues(key, [`<${value}`]); break; } // these actions do not modify the query in any way, // instead they have side effects case Actions.RELEASE: case Actions.DRILLDOWN: break; default: throw new Error(`Unknown action type. ${action}`); } } export function addToFilter( oldFilter: MutableSearch, key: string, value: React.ReactText | string[] ) { // Remove exclusion if it exists. oldFilter.removeFilter(`!${key}`); if (Array.isArray(value)) { // For array values, add to existing filters const currentFilters = oldFilter.getFilterValues(key); value = [...new Set([...currentFilters, ...value])]; } else { value = [String(value)]; } oldFilter.setFilterValues(key, value); } export function excludeFromFilter( oldFilter: MutableSearch, key: string, value: React.ReactText | string[] ) { // Remove positive if it exists. oldFilter.removeFilter(key); // Negations should stack up. const negation = `!${key}`; value = Array.isArray(value) ? value : [String(value)]; const currentNegations = oldFilter.getFilterValues(negation); oldFilter.removeFilter(negation); // We shouldn't escape any of the existing conditions since the // existing conditions have already been set an verified by the user oldFilter.addFilterValues( negation, currentNegations.filter(filterValue => !(value as string[]).includes(filterValue)), false ); // Escapes the new condition if necessary oldFilter.addFilterValues(negation, value); } type CellActionsOpts = { column: TableColumn; dataRow: TableDataRow; handleCellAction: (action: Actions, value: React.ReactText) => void; /** * allow list of actions to display on the context menu */ allowActions?: Actions[]; children?: React.ReactNode; }; function makeCellActions({ dataRow, column, handleCellAction, allowActions, }: CellActionsOpts) { // Do not render context menu buttons for the span op breakdown field. if (isRelativeSpanOperationBreakdownField(column.name)) { return null; } // Do not render context menu buttons for the equation fields until we can query on them if (isEquationAlias(column.name)) { return null; } let value = dataRow[column.name]; // error.handled is a strange field where null = true. if ( Array.isArray(value) && value[0] === null && column.column.kind === 'field' && column.column.field === 'error.handled' ) { value = 1; } const actions: MenuItemProps[] = []; function addMenuItem( action: Actions, itemLabel: React.ReactNode, itemTextValue?: string ) { if ((Array.isArray(allowActions) && allowActions.includes(action)) || !allowActions) { actions.push({ key: action, label: itemLabel, textValue: itemTextValue, onAction: () => handleCellAction(action, value), }); } } if ( !['duration', 'number', 'percentage'].includes(column.type) || (value === null && column.column.kind === 'field') ) { addMenuItem(Actions.ADD, t('Add to filter')); if (column.type !== 'date') { addMenuItem(Actions.EXCLUDE, t('Exclude from filter')); } } if ( ['date', 'duration', 'integer', 'number', 'percentage'].includes(column.type) && value !== null ) { addMenuItem(Actions.SHOW_GREATER_THAN, t('Show values greater than')); addMenuItem(Actions.SHOW_LESS_THAN, t('Show values less than')); } if (column.column.kind === 'field' && column.column.field === 'release' && value) { addMenuItem(Actions.RELEASE, t('Go to release')); } if (column.column.kind === 'function' && column.column.function[0] === 'count_unique') { addMenuItem(Actions.DRILLDOWN, t('View Stacks')); } if ( column.column.kind === 'function' && column.column.function[0] === 'user_misery' && defined(dataRow.project_threshold_config) ) { addMenuItem( Actions.EDIT_THRESHOLD, tct('Edit threshold ([threshold]ms)', { threshold: dataRow.project_threshold_config[1], }), t('Edit threshold') ); } if (actions.length === 0) { return null; } return actions; } type Props = React.PropsWithoutRef; type State = { isHovering: boolean; isOpen: boolean; }; class CellAction extends Component { render() { const {children} = this.props; const cellActions = makeCellActions(this.props); return ( {children} {cellActions?.length && ( ( } size="zero" /> )} /> )} ); } } export default CellAction; const Container = styled('div')` position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; `; const ActionMenuTrigger = styled(Button)` position: absolute; top: 50%; right: -1px; transform: translateY(-50%); padding: ${space(0.5)}; display: flex; align-items: center; opacity: 0; transition: opacity 0.1s; &:focus-visible, &[aria-expanded='true'], ${Container}:hover & { opacity: 1; } `;