123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518 |
- import React from 'react';
- import ReactDOM from 'react-dom';
- import {Manager, Popper, Reference} from 'react-popper';
- import styled from '@emotion/styled';
- import color from 'color';
- import * as PopperJS from 'popper.js';
- import {IconEllipsis} from 'app/icons';
- import {t} from 'app/locale';
- import space from 'app/styles/space';
- import {TableDataRow} from 'app/utils/discover/discoverQuery';
- import {getAggregateAlias} from 'app/utils/discover/fields';
- import {getDuration} from 'app/utils/formatters';
- import {QueryResults} from 'app/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',
- }
- export function updateQuery(
- results: QueryResults,
- 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.removeTagValue('has', key);
- results.addTagValues('!has', [key]);
- } else {
- // Remove exclusion if it exists.
- results.removeTag(`!${key}`);
- if (Array.isArray(value)) {
- // For array values, add to existing filters
- const currentFilters = results.getTagValues(key);
- value = [...new Set([...currentFilters, ...value])];
- } else {
- value = [String(value)];
- }
- results.setTagValues(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.removeTagValue('!has', key);
- results.addTagValues('has', [key]);
- } else {
- // Remove positive if it exists.
- results.removeTag(key);
- // Negations should stack up.
- const negation = `!${key}`;
- value = Array.isArray(value) ? value : [String(value)];
- const currentNegations = results.getTagValues(negation);
- value = [...new Set([...currentNegations, ...value])];
- results.setTagValues(negation, value);
- }
- break;
- case Actions.SHOW_GREATER_THAN: {
- // Remove query token if it already exists
- results.setTagValues(key, [`>${value}`]);
- break;
- }
- case Actions.SHOW_LESS_THAN: {
- // Remove query token if it already exists
- results.setTagValues(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}`);
- }
- }
- type Props = {
- column: TableColumn<keyof TableDataRow>;
- dataRow: TableDataRow;
- children: React.ReactNode;
- handleCellAction: (action: Actions, value: React.ReactText) => void;
- // allow list of actions to display on the context menu
- allowActions?: Actions[];
- };
- type State = {
- isHovering: boolean;
- isOpen: boolean;
- };
- class CellAction extends React.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 = {
- 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});
- };
- renderMenuButtons() {
- const {dataRow, column, handleCellAction, allowActions} = this.props;
- const fieldAlias = getAggregateAlias(column.name);
- let value = dataRow[fieldAlias];
- // 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 (actions.length === 0) {
- return null;
- }
- return (
- <MenuButtons
- onClick={event => {
- // prevent clicks from propagating further
- event.stopPropagation();
- }}
- >
- {actions}
- </MenuButtons>
- );
- }
- renderMenu() {
- const {isOpen} = this.state;
- const menuButtons = this.renderMenuButtons();
- if (menuButtons === null) {
- // do not render the menu if there are no per cell actions
- return null;
- }
- const modifiers: PopperJS.Modifiers = {
- hide: {
- enabled: false,
- },
- preventOverflow: {
- padding: 10,
- enabled: true,
- boundariesElement: 'viewport',
- },
- };
- let menu: React.ReactPortal | null = null;
- if (isOpen) {
- menu = ReactDOM.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}
- </Menu>
- )}
- </Popper>,
- this.portalEl
- );
- }
- return (
- <MenuRoot>
- <Manager>
- <Reference>
- {({ref}) => (
- <MenuButton ref={ref} onClick={this.handleMenuToggle}>
- <IconEllipsis size="sm" data-test-id="cell-action" color="blue300" />
- </MenuButton>
- )}
- </Reference>
- {menu}
- </Manager>
- </MenuRoot>
- );
- }
- render() {
- const {children} = this.props;
- const {isHovering} = this.state;
- return (
- <Container
- onMouseEnter={this.handleMouseEnter}
- onMouseLeave={this.handleMouseLeave}
- >
- {children}
- {isHovering && this.renderMenu()}
- </Container>
- );
- }
- }
- export default CellAction;
- const Container = styled('div')`
- position: relative;
- width: 100%;
- height: 100%;
- `;
- const MenuRoot = styled('div')`
- position: absolute;
- top: 0;
- right: 0;
- `;
- const Menu = styled('div')`
- margin: ${space(1)} 0;
- 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 MenuArrow = styled('span')`
- position: absolute;
- width: 18px;
- height: 9px;
- /* left and top set by popper */
- &[data-placement*='bottom'] {
- margin-top: -9px;
- &::before {
- border-width: 0 9px 9px 9px;
- border-color: transparent transparent ${p => p.theme.border} transparent;
- }
- &::after {
- top: 1px;
- left: 1px;
- border-width: 0 8px 8px 8px;
- border-color: transparent transparent ${p => p.theme.background} transparent;
- }
- }
- &[data-placement*='top'] {
- margin-bottom: -8px;
- bottom: 0;
- &::before {
- border-width: 9px 9px 0 9px;
- border-color: ${p => p.theme.border} transparent transparent transparent;
- }
- &::after {
- bottom: 1px;
- left: 1px;
- border-width: 8px 8px 0 8px;
- border-color: ${p => p.theme.background} transparent transparent transparent;
- }
- }
- &::before,
- &::after {
- width: 0;
- height: 0;
- content: '';
- display: block;
- position: absolute;
- border-style: solid;
- }
- `;
- 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;
- `;
|