123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- import {Component} from 'react';
- import styled from '@emotion/styled';
- import {Button} from 'sentry/components/button';
- import {DropdownMenu, MenuItemProps} 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 {TableDataRow} from 'sentry/utils/discover/discoverQuery';
- import {
- isEquationAlias,
- isRelativeSpanOperationBreakdownField,
- } from 'sentry/utils/discover/fields';
- import {getDuration} from 'sentry/utils/formatters';
- import {MutableSearch} from 'sentry/utils/tokenizeSearch';
- import {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<keyof TableDataRow>,
- 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<keyof TableDataRow>;
- 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<CellActionsOpts>;
- type State = {
- isHovering: boolean;
- isOpen: boolean;
- };
- class CellAction extends Component<Props, State> {
- render() {
- const {children} = this.props;
- const cellActions = makeCellActions(this.props);
- return (
- <Container data-test-id="cell-action-container">
- {children}
- {cellActions?.length && (
- <DropdownMenu
- items={cellActions}
- usePortal
- size="sm"
- offset={4}
- position="bottom"
- preventOverflowOptions={{padding: 4}}
- flipOptions={{
- fallbackPlacements: [
- 'top',
- 'right-start',
- 'right-end',
- 'left-start',
- 'left-end',
- ],
- }}
- trigger={triggerProps => (
- <ActionMenuTrigger
- {...triggerProps}
- translucentBorder
- aria-label={t('Actions')}
- icon={<IconEllipsis size="xs" />}
- size="zero"
- />
- )}
- />
- )}
- </Container>
- );
- }
- }
- 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;
- }
- `;
|