mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
321 lines
8.3 KiB
TypeScript
321 lines
8.3 KiB
TypeScript
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/formatters';
|
|
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<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;
|
|
}
|
|
`;
|