123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611 |
- import React, {Component} from 'react';
- import {createPortal} from 'react-dom';
- import {Manager, Popper, Reference} from 'react-popper';
- import styled from '@emotion/styled';
- import color from 'color';
- 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',
- TRANSACTION = 'transaction',
- 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.TRANSACTION:
- 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: React.ReactNode[] = [];
- function addMenuItem(action: Actions, menuItem: React.ReactNode) {
- if ((Array.isArray(allowActions) && allowActions.includes(action)) || !allowActions) {
- actions.push(menuItem);
- }
- }
- if (
- !['duration', 'number', 'percentage'].includes(column.type) ||
- (value === null && column.column.kind === 'field')
- ) {
- addMenuItem(
- Actions.ADD,
- <ActionItem
- key="add-to-filter"
- data-test-id="add-to-filter"
- onClick={() => handleCellAction(Actions.ADD, value)}
- >
- {t('Add to filter')}
- </ActionItem>
- );
- if (column.type !== 'date') {
- addMenuItem(
- Actions.EXCLUDE,
- <ActionItem
- key="exclude-from-filter"
- data-test-id="exclude-from-filter"
- onClick={() => handleCellAction(Actions.EXCLUDE, value)}
- >
- {t('Exclude from filter')}
- </ActionItem>
- );
- }
- }
- if (
- ['date', 'duration', 'integer', 'number', 'percentage'].includes(column.type) &&
- value !== null
- ) {
- addMenuItem(
- Actions.SHOW_GREATER_THAN,
- <ActionItem
- key="show-values-greater-than"
- data-test-id="show-values-greater-than"
- onClick={() => handleCellAction(Actions.SHOW_GREATER_THAN, value)}
- >
- {t('Show values greater than')}
- </ActionItem>
- );
- addMenuItem(
- Actions.SHOW_LESS_THAN,
- <ActionItem
- key="show-values-less-than"
- data-test-id="show-values-less-than"
- onClick={() => handleCellAction(Actions.SHOW_LESS_THAN, value)}
- >
- {t('Show values less than')}
- </ActionItem>
- );
- }
- if (column.column.kind === 'field' && column.column.field === 'transaction') {
- addMenuItem(
- Actions.TRANSACTION,
- <ActionItem
- key="transaction-summary"
- data-test-id="transaction-summary"
- onClick={() => handleCellAction(Actions.TRANSACTION, value)}
- >
- {t('Go to summary')}
- </ActionItem>
- );
- }
- if (column.column.kind === 'field' && column.column.field === 'release' && value) {
- addMenuItem(
- Actions.RELEASE,
- <ActionItem
- key="release"
- data-test-id="release"
- onClick={() => handleCellAction(Actions.RELEASE, value)}
- >
- {t('Go to release')}
- </ActionItem>
- );
- }
- if (column.column.kind === 'function' && column.column.function[0] === 'count_unique') {
- addMenuItem(
- Actions.DRILLDOWN,
- <ActionItem
- key="drilldown"
- data-test-id="per-cell-drilldown"
- onClick={() => handleCellAction(Actions.DRILLDOWN, value)}
- >
- {t('View Stacks')}
- </ActionItem>
- );
- }
- if (
- column.column.kind === 'function' &&
- column.column.function[0] === 'user_misery' &&
- defined(dataRow.project_threshold_config)
- ) {
- addMenuItem(
- Actions.EDIT_THRESHOLD,
- <ActionItem
- key="edit_threshold"
- data-test-id="edit-threshold"
- onClick={() => handleCellAction(Actions.EDIT_THRESHOLD, value)}
- >
- {tct('Edit threshold ([threshold]ms)', {
- threshold: dataRow.project_threshold_config[1],
- })}
- </ActionItem>
- );
- }
- 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> {
- constructor(props: Props) {
- super(props);
- let portal = document.getElementById('cell-action-portal');
- if (!portal) {
- portal = document.createElement('div');
- portal.setAttribute('id', 'cell-action-portal');
- document.body.appendChild(portal);
- }
- this.portalEl = portal;
- this.menuEl = null;
- }
- state: State = {
- isHovering: false,
- isOpen: false,
- };
- componentDidUpdate(_props: Props, prevState: State) {
- if (this.state.isOpen && prevState.isOpen === false) {
- document.addEventListener('click', this.handleClickOutside, true);
- }
- if (this.state.isOpen === false && prevState.isOpen) {
- document.removeEventListener('click', this.handleClickOutside, true);
- }
- }
- componentWillUnmount() {
- document.removeEventListener('click', this.handleClickOutside, true);
- }
- private portalEl: Element;
- private menuEl: Element | null;
- handleClickOutside = (event: MouseEvent) => {
- if (!this.menuEl) {
- return;
- }
- if (!(event.target instanceof Element)) {
- return;
- }
- if (this.menuEl.contains(event.target)) {
- return;
- }
- this.setState({isOpen: false, isHovering: false});
- };
- handleMouseEnter = () => {
- this.setState({isHovering: true});
- };
- handleMouseLeave = () => {
- this.setState(state => {
- // Don't hide the button if the menu is open.
- if (state.isOpen) {
- return state;
- }
- return {...state, isHovering: false};
- });
- };
- handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
- event.preventDefault();
- this.setState({isOpen: !this.state.isOpen});
- };
- renderMenu() {
- const {isOpen} = this.state;
- const actions = makeCellActions(this.props);
- if (actions === null) {
- // do not render the menu if there are no per cell actions
- return null;
- }
- const modifiers = [
- {
- name: 'hide',
- enabled: false,
- },
- {
- name: 'preventOverflow',
- enabled: true,
- options: {
- padding: 10,
- altAxis: true,
- },
- },
- {
- name: 'offset',
- options: {
- offset: [0, ARROW_SIZE / 2],
- },
- },
- {
- name: 'computeStyles',
- options: {
- // Using the `transform` attribute causes our borders to get blurry
- // in chrome. See [0]. This just causes it to use `top` / `left`
- // positions, which should be fine.
- //
- // [0]: https://stackoverflow.com/questions/29543142/css3-transformation-blurry-borders
- gpuAcceleration: false,
- },
- },
- ];
- const menu = !isOpen
- ? null
- : createPortal(
- <Popper placement="top" modifiers={modifiers}>
- {({ref: popperRef, style, placement, arrowProps}) => (
- <Menu
- ref={ref => {
- (popperRef as Function)(ref);
- this.menuEl = ref;
- }}
- style={style}
- >
- <MenuArrow
- ref={arrowProps.ref}
- data-placement={placement}
- style={arrowProps.style}
- />
- <MenuButtons onClick={event => event.stopPropagation()}>
- {actions}
- </MenuButtons>
- </Menu>
- )}
- </Popper>,
- this.portalEl
- );
- return (
- <MenuRoot>
- <Manager>
- <Reference>
- {({ref}) => (
- <MenuButton ref={ref} onClick={this.handleMenuToggle}>
- <IconEllipsis size="sm" data-test-id="cell-action" color="linkColor" />
- </MenuButton>
- )}
- </Reference>
- {menu}
- </Manager>
- </MenuRoot>
- );
- }
- render() {
- const {children} = this.props;
- const {isHovering} = this.state;
- return (
- <Container
- onMouseEnter={this.handleMouseEnter}
- onMouseLeave={this.handleMouseLeave}
- data-test-id="cell-action-container"
- >
- {children}
- {isHovering && this.renderMenu()}
- </Container>
- );
- }
- }
- export default CellAction;
- const Container = styled('div')`
- position: relative;
- width: 100%;
- height: 100%;
- display: flex;
- flex-direction: column;
- justify-content: center;
- `;
- const MenuRoot = styled('div')`
- position: absolute;
- top: 0;
- right: 0;
- `;
- const Menu = styled('div')`
- z-index: ${p => p.theme.zIndex.tooltip};
- `;
- const MenuButtons = styled('div')`
- background: ${p => p.theme.background};
- border: 1px solid ${p => p.theme.border};
- border-radius: ${p => p.theme.borderRadius};
- box-shadow: ${p => p.theme.dropShadowHeavy};
- overflow: hidden;
- `;
- const ARROW_SIZE = 12;
- const MenuArrow = styled('span')`
- pointer-events: none;
- position: absolute;
- width: ${ARROW_SIZE}px;
- height: ${ARROW_SIZE}px;
- &::before,
- &::after {
- content: '';
- display: block;
- position: absolute;
- height: ${ARROW_SIZE}px;
- width: ${ARROW_SIZE}px;
- border: solid 6px transparent;
- }
- &[data-placement|='bottom'] {
- top: -${ARROW_SIZE}px;
- &::before {
- bottom: 1px;
- border-bottom-color: ${p => p.theme.translucentBorder};
- }
- &::after {
- border-bottom-color: ${p => p.theme.backgroundElevated};
- }
- }
- &[data-placement|='top'] {
- bottom: -${ARROW_SIZE}px;
- &::before {
- top: 1px;
- border-top-color: ${p => p.theme.translucentBorder};
- }
- &::after {
- border-top-color: ${p => p.theme.backgroundElevated};
- }
- }
- &[data-placement|='right'] {
- left: -${ARROW_SIZE}px;
- &::before {
- right: 1px;
- border-right-color: ${p => p.theme.translucentBorder};
- }
- &::after {
- border-right-color: ${p => p.theme.backgroundElevated};
- }
- }
- &[data-placement|='left'] {
- right: -${ARROW_SIZE}px;
- &::before {
- left: 1px;
- border-left-color: ${p => p.theme.translucentBorder};
- }
- &::after {
- border-left-color: ${p => p.theme.backgroundElevated};
- }
- }
- `;
- const ActionItem = styled('button')`
- display: block;
- width: 100%;
- padding: ${space(1)} ${space(2)};
- background: transparent;
- outline: none;
- border: 0;
- border-bottom: 1px solid ${p => p.theme.innerBorder};
- font-size: ${p => p.theme.fontSizeMedium};
- text-align: left;
- line-height: 1.2;
- &:hover {
- background: ${p => p.theme.backgroundSecondary};
- }
- &:last-child {
- border-bottom: 0;
- }
- `;
- const MenuButton = styled('button')`
- display: flex;
- width: 24px;
- height: 24px;
- padding: 0;
- justify-content: center;
- align-items: center;
- background: ${p => color(p.theme.background).alpha(0.85).string()};
- border-radius: ${p => p.theme.borderRadius};
- border: 1px solid ${p => p.theme.border};
- cursor: pointer;
- outline: none;
- `;
|