Commit 72b83005 by Hugo Häggmark Committed by GitHub

Table: Adds adhoc filtering (#25467)

* Table: Adds adhoc filtering

* Refactor: changes after PR comments

* Refactor: hides filtering for data sources that do not support modifyQuery in Explore

* Refactor: fixes strict null error

* Changed tooltip position to above icon

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
parent 1040d824
import React, { FC, useCallback, useState } from 'react';
import { TableCellProps } from 'react-table';
import { GrafanaTheme } from '@grafana/data';
import { css, cx } from 'emotion';
import { stylesFactory, useTheme } from '../../themes';
import { TableStyles } from './styles';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, TableFilterActionCallback } from './types';
import { Icon, Tooltip } from '..';
import { Props, renderCell } from './TableCell';
interface FilterableTableCellProps extends Pick<Props, 'cell' | 'field' | 'tableStyles'> {
onCellFilterAdded: TableFilterActionCallback;
cellProps: TableCellProps;
}
export const FilterableTableCell: FC<FilterableTableCellProps> = ({
cell,
field,
tableStyles,
onCellFilterAdded,
cellProps,
}) => {
const [showFilters, setShowFilter] = useState(false);
const onMouseOver = useCallback((event: React.MouseEvent<HTMLDivElement>) => setShowFilter(true), [setShowFilter]);
const onMouseLeave = useCallback((event: React.MouseEvent<HTMLDivElement>) => setShowFilter(false), [setShowFilter]);
const onFilterFor = useCallback(
(event: React.MouseEvent<HTMLDivElement>) =>
onCellFilterAdded({ key: field.name, operator: FILTER_FOR_OPERATOR, value: cell.value }),
[cell, field, onCellFilterAdded]
);
const onFilterOut = useCallback(
(event: React.MouseEvent<HTMLDivElement>) =>
onCellFilterAdded({ key: field.name, operator: FILTER_OUT_OPERATOR, value: cell.value }),
[cell, field, onCellFilterAdded]
);
const theme = useTheme();
const styles = getFilterableTableCellStyles(theme, tableStyles);
return (
<div
{...cellProps}
className={showFilters ? styles.tableCellWrapper : tableStyles.tableCellWrapper}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
>
{renderCell(cell, field, tableStyles)}
{showFilters && cell.value && (
<div className={styles.filterWrapper}>
<div className={styles.filterItem}>
<Tooltip content="Filter for value" placement="top">
<Icon name={'search-plus'} onClick={onFilterFor} />
</Tooltip>
</div>
<div className={styles.filterItem}>
<Tooltip content="Filter out value" placement="top">
<Icon name={'search-minus'} onClick={onFilterOut} />
</Tooltip>
</div>
</div>
)}
</div>
);
};
const getFilterableTableCellStyles = stylesFactory((theme: GrafanaTheme, tableStyles: TableStyles) => ({
tableCellWrapper: cx(
tableStyles.tableCellWrapper,
css`
display: inline-flex;
justify-content: space-between;
align-items: center;
`
),
filterWrapper: css`
label: filterWrapper;
display: inline-flex;
justify-content: space-around;
cursor: pointer;
`,
filterItem: css`
label: filterItem;
color: ${theme.colors.textSemiWeak};
padding: 0 ${theme.spacing.xxs};
`,
}));
......@@ -36,9 +36,9 @@ export interface Props {
noHeader?: boolean;
resizable?: boolean;
initialSortBy?: TableSortByFieldState[];
onCellClick?: TableFilterActionCallback;
onColumnResize?: TableColumnResizeActionCallback;
onSortByChange?: TableSortByActionCallback;
onCellFilterAdded?: TableFilterActionCallback;
}
interface ReactTableInternalState extends UseResizeColumnsState<{}>, UseSortByState<{}> {}
......@@ -110,7 +110,15 @@ function getInitialState(props: Props, columns: Column[]): Partial<ReactTableInt
}
export const Table: FC<Props> = memo((props: Props) => {
const { data, height, onCellClick, width, columnMinWidth = COLUMN_MIN_WIDTH, noHeader, resizable = true } = props;
const {
data,
height,
onCellFilterAdded,
width,
columnMinWidth = COLUMN_MIN_WIDTH,
noHeader,
resizable = true,
} = props;
const theme = useTheme();
const tableStyles = getTableStyles(theme);
......@@ -162,7 +170,7 @@ export const Table: FC<Props> = memo((props: Props) => {
field={data.fields[index]}
tableStyles={tableStyles}
cell={cell}
onCellClick={onCellClick}
onCellFilterAdded={onCellFilterAdded}
/>
))}
</div>
......
import React, { FC } from 'react';
import { Cell } from 'react-table';
import { Field } from '@grafana/data';
import { getTextAlign } from './utils';
import { TableFilterActionCallback } from './types';
import { TableStyles } from './styles';
import { FilterableTableCell } from './FilterableTableCell';
interface Props {
export interface Props {
cell: Cell;
field: Field;
tableStyles: TableStyles;
onCellClick?: TableFilterActionCallback;
onCellFilterAdded?: TableFilterActionCallback;
}
export const TableCell: FC<Props> = ({ cell, field, tableStyles, onCellClick }) => {
export const TableCell: FC<Props> = ({ cell, field, tableStyles, onCellFilterAdded }) => {
const filterable = field.config.filterable;
const cellProps = cell.getCellProps();
let onClick: ((event: React.SyntheticEvent) => void) | undefined = undefined;
if (filterable && onCellClick) {
if (cellProps.style) {
cellProps.style.cursor = 'pointer';
}
onClick = () => onCellClick(cell.column.Header as string, cell.value);
}
if (cellProps.style) {
cellProps.style.textAlign = getTextAlign(field);
}
if (filterable && onCellFilterAdded) {
return (
<FilterableTableCell
cell={cell}
field={field}
tableStyles={tableStyles}
onCellFilterAdded={onCellFilterAdded}
cellProps={cellProps}
/>
);
}
return (
<div {...cellProps} onClick={onClick} className={tableStyles.tableCellWrapper}>
{cell.render('Cell', { field, tableStyles })}
<div {...cellProps} className={tableStyles.tableCellWrapper}>
{renderCell(cell, field, tableStyles)}
</div>
);
};
export const renderCell = (cell: Cell, field: Field, tableStyles: TableStyles) =>
cell.render('Cell', { field, tableStyles });
......@@ -25,7 +25,11 @@ export interface TableRow {
[x: string]: any;
}
export type TableFilterActionCallback = (key: string, value: string) => void;
export const FILTER_FOR_OPERATOR = '=';
export const FILTER_OUT_OPERATOR = '!=';
export type FilterOperator = typeof FILTER_FOR_OPERATOR | typeof FILTER_OUT_OPERATOR;
export type FilterItem = { key: string; value: string; operator: FilterOperator };
export type TableFilterActionCallback = (item: FilterItem) => void;
export type TableColumnResizeActionCallback = (fieldDisplayName: string, width: number) => void;
export type TableSortByActionCallback = (state: TableSortByFieldState[]) => void;
......
......@@ -58,6 +58,7 @@ import { scanStopAction } from './state/actionTypes';
import { ExploreGraphPanel } from './ExploreGraphPanel';
import { TraceView } from './TraceView/TraceView';
import { SecondaryActions } from './SecondaryActions';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, FilterItem } from '@grafana/ui/src/components/Table/types';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
......@@ -211,6 +212,17 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
this.props.setQueries(this.props.exploreId, [query]);
};
onCellFilterAdded = (filter: FilterItem) => {
const { value, key, operator } = filter;
if (operator === FILTER_FOR_OPERATOR) {
this.onClickFilterLabel(key, value);
}
if (operator === FILTER_OUT_OPERATOR) {
this.onClickFilterOutLabel(key, value);
}
};
onClickFilterLabel = (key: string, value: string) => {
this.onModifyQueries({ type: 'ADD_FILTER', key, value });
};
......@@ -366,7 +378,13 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
/>
)}
{mode === ExploreMode.Metrics && (
<TableContainer width={width} exploreId={exploreId} onClickCell={this.onClickFilterLabel} />
<TableContainer
width={width}
exploreId={exploreId}
onCellFilterAdded={
this.props.datasourceInstance?.modifyQuery ? this.onCellFilterAdded : undefined
}
/>
)}
{mode === ExploreMode.Logs && (
<LogsContainer
......
import React from 'react';
import { shallow, render } from 'enzyme';
import { render, shallow } from 'enzyme';
import { TableContainer } from './TableContainer';
import { DataFrame } from '@grafana/data';
import { toggleTable } from './state/actions';
......@@ -11,7 +11,7 @@ describe('TableContainer', () => {
exploreId: ExploreId.left as ExploreId,
loading: false,
width: 800,
onClickCell: jest.fn(),
onCellFilterAdded: jest.fn(),
showingTable: true,
tableResult: {} as DataFrame,
toggleTable: {} as typeof toggleTable,
......@@ -26,7 +26,7 @@ describe('TableContainer', () => {
exploreId: ExploreId.left as ExploreId,
loading: false,
width: 800,
onClickCell: jest.fn(),
onCellFilterAdded: jest.fn(),
showingTable: true,
tableResult: {
name: 'TableResultName',
......
......@@ -2,19 +2,20 @@ import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { DataFrame } from '@grafana/data';
import { Table, Collapse } from '@grafana/ui';
import { Collapse, Table } from '@grafana/ui';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types';
import { toggleTable } from './state/actions';
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
import { MetaInfoText } from './MetaInfoText';
import { FilterItem } from '@grafana/ui/src/components/Table/types';
interface TableContainerProps {
exploreId: ExploreId;
loading: boolean;
width: number;
onClickCell: (key: string, value: string) => void;
onCellFilterAdded?: (filter: FilterItem) => void;
showingTable: boolean;
tableResult?: DataFrame;
toggleTable: typeof toggleTable;
......@@ -37,7 +38,7 @@ export class TableContainer extends PureComponent<TableContainerProps> {
}
render() {
const { loading, onClickCell, showingTable, tableResult, width } = this.props;
const { loading, onCellFilterAdded, showingTable, tableResult, width } = this.props;
const height = this.getTableHeight();
const tableWidth = width - config.theme.panelPadding * 2 - PANEL_BORDER;
......@@ -46,7 +47,7 @@ export class TableContainer extends PureComponent<TableContainerProps> {
return (
<Collapse label="Table" loading={loading} collapsible isOpen={showingTable} onToggle={this.onClickTableButton}>
{hasTableResult ? (
<Table data={tableResult!} width={tableWidth} height={height} onCellClick={onClickCell} />
<Table data={tableResult!} width={tableWidth} height={height} onCellFilterAdded={onCellFilterAdded} />
) : (
<MetaInfoText metaItems={[{ value: '0 series returned' }]} />
)}
......
......@@ -20,6 +20,7 @@ import { PromOptions, PromQuery } from './types';
import templateSrv from 'app/features/templating/template_srv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { VariableHide } from '../../../features/variables/types';
import { describe } from '../../../../test/lib/common';
const datasourceRequestMock = jest.fn().mockResolvedValue(createDefaultPromResponse());
......@@ -1886,6 +1887,68 @@ describe('prepareTargets', () => {
});
});
describe('modifyQuery', () => {
describe('when called with ADD_FILTER', () => {
describe('and query has no labels', () => {
it('then the correct label should be added', () => {
const query: PromQuery = { refId: 'A', expr: 'go_goroutines' };
const action = { key: 'cluster', value: 'us-cluster', type: 'ADD_FILTER' };
const instanceSettings = ({ jsonData: {} } as unknown) as DataSourceInstanceSettings<PromOptions>;
const ds = new PrometheusDatasource(instanceSettings);
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('go_goroutines{cluster="us-cluster"}');
});
});
describe('and query has labels', () => {
it('then the correct label should be added', () => {
const query: PromQuery = { refId: 'A', expr: 'go_goroutines{cluster="us-cluster"}' };
const action = { key: 'pod', value: 'pod-123', type: 'ADD_FILTER' };
const instanceSettings = ({ jsonData: {} } as unknown) as DataSourceInstanceSettings<PromOptions>;
const ds = new PrometheusDatasource(instanceSettings);
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('go_goroutines{cluster="us-cluster",pod="pod-123"}');
});
});
});
describe('when called with ADD_FILTER_OUT', () => {
describe('and query has no labels', () => {
it('then the correct label should be added', () => {
const query: PromQuery = { refId: 'A', expr: 'go_goroutines' };
const action = { key: 'cluster', value: 'us-cluster', type: 'ADD_FILTER_OUT' };
const instanceSettings = ({ jsonData: {} } as unknown) as DataSourceInstanceSettings<PromOptions>;
const ds = new PrometheusDatasource(instanceSettings);
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('go_goroutines{cluster!="us-cluster"}');
});
});
describe('and query has labels', () => {
it('then the correct label should be added', () => {
const query: PromQuery = { refId: 'A', expr: 'go_goroutines{cluster="us-cluster"}' };
const action = { key: 'pod', value: 'pod-123', type: 'ADD_FILTER_OUT' };
const instanceSettings = ({ jsonData: {} } as unknown) as DataSourceInstanceSettings<PromOptions>;
const ds = new PrometheusDatasource(instanceSettings);
const result = ds.modifyQuery(query, action);
expect(result.refId).toEqual('A');
expect(result.expr).toEqual('go_goroutines{cluster="us-cluster",pod!="pod-123"}');
});
});
});
});
function createDataRequest(targets: any[], overrides?: Partial<DataQueryRequest>): DataQueryRequest<PromQuery> {
const defaults = {
app: CoreApp.Dashboard,
......
......@@ -693,6 +693,10 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
expression = addLabelToQuery(expression, action.key, action.value);
break;
}
case 'ADD_FILTER_OUT': {
expression = addLabelToQuery(expression, action.key, action.value, '!=');
break;
}
case 'ADD_HISTOGRAM_QUANTILE': {
expression = `histogram_quantile(0.95, sum(rate(${expression}[5m])) by (le))`;
break;
......
import React, { Component } from 'react';
import { Table, Select } from '@grafana/ui';
import { FieldMatcherID, PanelProps, DataFrame, SelectableValue, getFrameDisplayName } from '@grafana/data';
import { Select, Table } from '@grafana/ui';
import { DataFrame, FieldMatcherID, getFrameDisplayName, PanelProps, SelectableValue } from '@grafana/data';
import { Options } from './types';
import { css } from 'emotion';
import { config } from 'app/core/config';
import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
import { FilterItem, TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
import { dispatch } from '../../../store/store';
import { applyFilterFromTable } from '../../../features/variables/adhoc/actions';
import { getDashboardSrv } from '../../../features/dashboard/services/DashboardSrv';
interface Props extends PanelProps<Options> {}
......@@ -62,6 +65,20 @@ export class TablePanel extends Component<Props> {
this.forceUpdate();
};
onCellFilterAdded = (filter: FilterItem) => {
const { key, value, operator } = filter;
const panelModel = getDashboardSrv()
.getCurrent()
.getPanelById(this.props.id);
const datasource = panelModel?.datasource;
if (!datasource) {
return;
}
dispatch(applyFilterFromTable({ datasource, key, operator, value }));
};
renderTable(frame: DataFrame, width: number, height: number) {
const { options } = this.props;
......@@ -75,6 +92,7 @@ export class TablePanel extends Component<Props> {
initialSortBy={options.sortBy}
onSortByChange={this.onSortByChange}
onColumnResize={this.onColumnResize}
onCellFilterAdded={this.onCellFilterAdded}
/>
);
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment