[Lens] Filters aggregation (#75635)

This commit is contained in:
Marta Bondyra 2020-09-10 21:16:07 +02:00 committed by GitHub
parent 65abdfffee
commit 0c678ebada
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1050 additions and 79 deletions

View file

@ -7,5 +7,5 @@
<b>Signature:</b>
```typescript
QueryStringInput: React.FC<Pick<Props, "query" | "prepend" | "size" | "className" | "placeholder" | "onChange" | "onBlur" | "onSubmit" | "indexPatterns" | "dataTestSubj" | "screenTitle" | "disableAutoFocus" | "persistedLog" | "bubbleSubmitEvent" | "languageSwitcherPopoverAnchorPosition" | "onChangeQueryInputFocus">>
QueryStringInput: React.FC<Pick<Props, "query" | "prepend" | "size" | "className" | "placeholder" | "onChange" | "onBlur" | "onSubmit" | "isInvalid" | "indexPatterns" | "dataTestSubj" | "screenTitle" | "disableAutoFocus" | "persistedLog" | "bubbleSubmitEvent" | "languageSwitcherPopoverAnchorPosition" | "onChangeQueryInputFocus">>
```

View file

@ -26,13 +26,6 @@
border-radius: 0;
border-left-width: 0;
}
.kuiLocalSearchAssistedInput {
display: flex;
flex: 1 1 100%;
position: relative;
}
/**
* 1. em used for right padding so documentation link and query string
* won't overlap if the user increases their default browser font size

View file

@ -1479,7 +1479,7 @@ export interface QueryStateChange extends QueryStateChangePartial {
// Warning: (ae-missing-release-tag) "QueryStringInput" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const QueryStringInput: React.FC<Pick<Props_3, "query" | "prepend" | "size" | "className" | "placeholder" | "onChange" | "onBlur" | "onSubmit" | "indexPatterns" | "dataTestSubj" | "screenTitle" | "disableAutoFocus" | "persistedLog" | "bubbleSubmitEvent" | "languageSwitcherPopoverAnchorPosition" | "onChangeQueryInputFocus">>;
export const QueryStringInput: React.FC<Pick<Props_3, "query" | "prepend" | "size" | "className" | "placeholder" | "onChange" | "onBlur" | "onSubmit" | "isInvalid" | "indexPatterns" | "dataTestSubj" | "screenTitle" | "disableAutoFocus" | "persistedLog" | "bubbleSubmitEvent" | "languageSwitcherPopoverAnchorPosition" | "onChangeQueryInputFocus">>;
// @public (undocumented)
export type QuerySuggestion = QuerySuggestionBasic | QuerySuggestionField;

View file

@ -8,30 +8,37 @@
border-right: none !important;
}
.kbnQueryBar__textareaWrap {
overflow: visible !important; // Override EUI form control
display: flex;
flex: 1 1 100%;
position: relative;
}
.kbnQueryBar__textarea {
z-index: $euiZContentMenu;
resize: none !important; // When in the group, it will autosize
height: $euiSizeXXL;
height: $euiFormControlHeight;
// Unlike most inputs within layout control groups, the text area still needs a border.
// These adjusts help it sit above the control groups shadow to line up correctly.
padding-top: $euiSizeS + 3px !important;
transform: translateY(-2px);
padding: $euiSizeS - 1px;
padding: $euiSizeS;
padding-top: $euiSizeS + 3px;
transform: translateY(-1px) translateX(-1px);
&:not(:focus):not(:invalid) {
@include euiYScrollWithShadows;
}
&:not(:focus) {
@include euiYScrollWithShadows;
white-space: nowrap;
overflow-y: hidden;
overflow-x: hidden;
border: none;
box-shadow: none;
}
// When focused, let it scroll
&:focus {
overflow-x: auto;
overflow-y: auto;
width: calc(100% + 1px); // To overtake the group's fake border
white-space: normal;
}
}

View file

@ -19,6 +19,7 @@
import React, { Component, RefObject, createRef } from 'react';
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import {
EuiTextArea,
@ -63,6 +64,7 @@ interface Props {
dataTestSubj?: string;
size?: SuggestionsListSize;
className?: string;
isInvalid?: boolean;
}
interface State {
@ -591,6 +593,7 @@ export class QueryStringInputUI extends Component<Props, State> {
'euiFormControlLayout euiFormControlLayout--group kbnQueryBar__wrap',
this.props.className
);
return (
<div className={className}>
{this.props.prepend}
@ -607,7 +610,7 @@ export class QueryStringInputUI extends Component<Props, State> {
>
<div
role="search"
className="euiFormControlLayout__childrenWrapper kuiLocalSearchAssistedInput"
className="euiFormControlLayout__childrenWrapper kbnQueryBar__textareaWrap"
ref={this.queryBarInputDivRefInstance}
>
<EuiTextArea
@ -651,6 +654,7 @@ export class QueryStringInputUI extends Component<Props, State> {
}
role="textbox"
data-test-subj={this.props.dataTestSubj || 'queryInput'}
isInvalid={this.props.isInvalid}
>
{this.getQueryString()}
</EuiTextArea>

View file

@ -33,4 +33,4 @@ export const SUGGESTIONS_LIST_REQUIRED_BOTTOM_SPACE = 250;
* A distance in px to display suggestions list right under the query input without a gap
* @public
*/
export const SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET = 2;
export const SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET = 1;

View file

@ -154,6 +154,7 @@ export class SuggestionsComponent extends Component<Props> {
const StyledSuggestionsListDiv = styled.div`
${(props: { queryBarRect: DOMRect; verticalListPosition: string }) => `
position: absolute;
z-index: 4001;
left: ${props.queryBarRect.left}px;
width: ${props.queryBarRect.width}px;
${props.verticalListPosition}`}

View file

@ -1,3 +1,2 @@
@import 'config_panel';
@import 'dimension_popover';
@import 'layer_panel';

View file

@ -43,6 +43,14 @@
min-height: $euiSizeXXL;
}
.lnsLayerPanel__anchor {
width: 100%;
}
.lnsLayerPanel__dndGrab {
padding: $euiSizeS;
}
.lnsLayerPanel__styleEditor {
width: $euiSize * 30;
padding: $euiSizeS;

View file

@ -9,3 +9,10 @@
display: block;
word-break: break-word;
}
// todo: remove after closing https://github.com/elastic/eui/issues/3548
.lnsDimensionPopover__fixTranslateDnd {
// sass-lint:disable-block no-important
transform: none !important;
}

View file

@ -3,6 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './dimension_popover.scss';
import React from 'react';
import { EuiPopover } from '@elastic/eui';
@ -31,6 +32,7 @@ export function DimensionPopover({
<EuiPopover
className="lnsDimensionPopover"
anchorClassName="lnsDimensionPopover__trigger"
panelClassName="lnsDimensionPopover__fixTranslateDnd"
isOpen={
popoverState.isOpen &&
(popoverState.openId === accessor || (noMatch && popoverState.addingToGroupId === groupId))

View file

@ -40,6 +40,7 @@ export function BucketNestingEditor({
value,
text: c.label,
fieldName: hasField(c) ? fieldMap[c.sourceField].displayName : '',
operationType: c.operationType,
}));
if (!column || !column.isBucketed || !aggColumns.length) {
@ -61,6 +62,36 @@ export function BucketNestingEditor({
}
}
// todo: move the copy to operations
const topLevelCopy: Record<string, string> = {
terms: i18n.translate('xpack.lens.indexPattern.groupingOverallTerms', {
defaultMessage: 'Overall top {field}',
values: { field: fieldName },
}),
filters: i18n.translate('xpack.lens.indexPattern.groupingOverallFilters', {
defaultMessage: 'Top values for each custom query',
}),
date_histogram: i18n.translate('xpack.lens.indexPattern.groupingOverallDateHistogram', {
defaultMessage: 'Top values for each {field}',
values: { field: fieldName },
}),
};
const bottomLevelCopy: Record<string, string> = {
terms: i18n.translate('xpack.lens.indexPattern.groupingSecondTerms', {
defaultMessage: 'Top values for each {target}',
values: { target: target.fieldName },
}),
filters: i18n.translate('xpack.lens.indexPattern.groupingSecondFilters', {
defaultMessage: 'Overall top {target}',
values: { target: target.fieldName },
}),
date_histogram: i18n.translate('xpack.lens.indexPattern.groupingSecondDateHistogram', {
defaultMessage: 'Overall top {target}',
values: { target: target.fieldName },
}),
};
return (
<>
<EuiHorizontalRule margin="m" />
@ -73,34 +104,14 @@ export function BucketNestingEditor({
<EuiRadio
id={generator('topLevel')}
data-test-subj="indexPattern-nesting-topLevel"
label={
column.operationType === 'terms'
? i18n.translate('xpack.lens.indexPattern.groupingOverallTerms', {
defaultMessage: 'Overall top {field}',
values: { field: fieldName },
})
: i18n.translate('xpack.lens.indexPattern.groupingOverallDateHistogram', {
defaultMessage: 'Top values for each {field}',
values: { field: fieldName },
})
}
label={topLevelCopy[column.operationType]}
checked={!prevColumn}
onChange={toggleNesting}
/>
<EuiRadio
id={generator('bottomLevel')}
data-test-subj="indexPattern-nesting-bottomLevel"
label={
column.operationType === 'terms'
? i18n.translate('xpack.lens.indexPattern.groupingSecondTerms', {
defaultMessage: 'Top values for each {target}',
values: { target: target.fieldName },
})
: i18n.translate('xpack.lens.indexPattern.groupingSecondDateHistogram', {
defaultMessage: 'Overall top {target}',
values: { target: target.fieldName },
})
}
label={bottomLevelCopy[column.operationType]}
checked={!!prevColumn}
onChange={toggleNesting}
/>

View file

@ -160,6 +160,11 @@ export function PopoverEditor(props: PopoverEditorProps) {
compatibleWithCurrentField ? '' : ' incompatible'
}`,
onClick() {
// todo: when moving from terms agg to filters, we want to create a filter `$field.name : *`
// it probably has to be re-thought when removing the field name.
const isTermsToFilters =
selectedColumn?.operationType === 'terms' && operationType === 'filters';
if (!selectedColumn || !compatibleWithCurrentField) {
const possibleFields = fieldByOperation[operationType] || [];
@ -186,7 +191,7 @@ export function PopoverEditor(props: PopoverEditorProps) {
trackUiEvent(`indexpattern_dimension_operation_${operationType}`);
return;
}
if (incompatibleSelectedOperationType) {
if (incompatibleSelectedOperationType && !isTermsToFilters) {
setInvalidOperationType(null);
}
if (selectedColumn.operationType === operationType) {

View file

@ -263,6 +263,7 @@ export function getIndexPatternDatasource({
data,
savedObjects: core.savedObjects,
docLinks: core.docLinks,
http: core.http,
}}
>
<IndexPatternDimensionEditor

View file

@ -59,7 +59,11 @@ export const cardinalityOperation: OperationDefinition<CardinalityIndexPatternCo
sourceField: field.name,
isBucketed: IS_BUCKETED,
params:
previousColumn && previousColumn.dataType === 'number' ? previousColumn.params : undefined,
previousColumn?.dataType === 'number' &&
previousColumn.params &&
'format' in previousColumn.params
? previousColumn.params
: undefined,
};
},
toEsAggsConfig: (column, columnId) => ({

View file

@ -49,7 +49,11 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn> = {
scale: 'ratio',
sourceField: field.name,
params:
previousColumn && previousColumn.dataType === 'number' ? previousColumn.params : undefined,
previousColumn?.dataType === 'number' &&
previousColumn.params &&
'format' in previousColumn.params
? previousColumn.params
: undefined,
};
},
toEsAggsConfig: (column, columnId) => ({

View file

@ -0,0 +1,3 @@
.lnsIndexPatternDimensionEditor__filtersEditor {
width: $euiSize * 60;
}

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { MouseEventHandler } from 'react';
import { shallow } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { EuiPopover, EuiLink } from '@elastic/eui';
import { createMockedIndexPattern } from '../../../mocks';
import { FilterPopover, QueryInput, LabelInput } from './filter_popover';
jest.mock('.', () => ({
isQueryValid: () => true,
defaultLabel: 'label',
}));
const defaultProps = {
filter: {
input: { query: 'bytes >= 1', language: 'kuery' },
label: 'More than one',
id: '1',
},
setFilter: jest.fn(),
indexPattern: createMockedIndexPattern(),
Button: ({ onClick }: { onClick: MouseEventHandler }) => (
<EuiLink onClick={onClick}>trigger</EuiLink>
),
isOpenByCreation: true,
setIsOpenByCreation: jest.fn(),
};
describe('filter popover', () => {
jest.mock('../../../../../../../../src/plugins/data/public', () => ({
QueryStringInput: () => {
return 'QueryStringInput';
},
}));
it('should be open if is open by creation', () => {
const setIsOpenByCreation = jest.fn();
const instance = shallow(
<FilterPopover {...defaultProps} setIsOpenByCreation={setIsOpenByCreation} />
);
expect(instance.find(EuiPopover).prop('isOpen')).toEqual(true);
act(() => {
instance.find(EuiPopover).prop('closePopover')!();
});
instance.update();
expect(setIsOpenByCreation).toHaveBeenCalledWith(false);
});
it('should call setFilter when modifying QueryInput', () => {
const setFilter = jest.fn();
const instance = shallow(<FilterPopover {...defaultProps} setFilter={setFilter} />);
instance.find(QueryInput).prop('onChange')!({
query: 'modified : query',
language: 'lucene',
});
expect(setFilter).toHaveBeenCalledWith({
input: {
language: 'lucene',
query: 'modified : query',
},
label: 'More than one',
id: '1',
});
});
it('should call setFilter when modifying LabelInput', () => {
const setFilter = jest.fn();
const instance = shallow(<FilterPopover {...defaultProps} setFilter={setFilter} />);
instance.find(LabelInput).prop('onChange')!('Modified label');
expect(setFilter).toHaveBeenCalledWith({
input: {
language: 'kuery',
query: 'bytes >= 1',
},
label: 'Modified label',
id: '1',
});
});
});

View file

@ -0,0 +1,193 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './filter_popover.scss';
import React, { MouseEventHandler, useState } from 'react';
import { useDebounce } from 'react-use';
import { EuiPopover, EuiFieldText, EuiSpacer, keys } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FilterValue, defaultLabel, isQueryValid } from '.';
import { IndexPattern } from '../../../types';
import { QueryStringInput, Query } from '../../../../../../../../src/plugins/data/public';
export const FilterPopover = ({
filter,
setFilter,
indexPattern,
Button,
isOpenByCreation,
setIsOpenByCreation,
}: {
filter: FilterValue;
setFilter: Function;
indexPattern: IndexPattern;
Button: React.FunctionComponent<{ onClick: MouseEventHandler }>;
isOpenByCreation: boolean;
setIsOpenByCreation: Function;
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const inputRef = React.useRef<HTMLInputElement>();
const setPopoverOpen = (isOpen: boolean) => {
setIsPopoverOpen(isOpen);
setIsOpenByCreation(isOpen);
};
const setFilterLabel = (label: string) => setFilter({ ...filter, label });
const setFilterQuery = (input: Query) => setFilter({ ...filter, input });
const getPlaceholder = (query: Query['query']) => {
if (query === '') {
return defaultLabel;
}
if (query === 'object') return JSON.stringify(query);
else {
return String(query);
}
};
return (
<EuiPopover
anchorClassName="eui-fullWidth"
panelClassName="lnsIndexPatternDimensionEditor__filtersEditor"
isOpen={isOpenByCreation || isPopoverOpen}
ownFocus
closePopover={() => {
setPopoverOpen(false);
}}
button={
<Button
onClick={() => {
setIsPopoverOpen((open) => !open);
setIsOpenByCreation(false);
}}
/>
}
>
<QueryInput
isInvalid={!isQueryValid(filter.input, indexPattern)}
value={filter.input}
indexPattern={indexPattern}
onChange={setFilterQuery}
onSubmit={() => {
if (inputRef.current) inputRef.current.focus();
}}
/>
<EuiSpacer size="s" />
<LabelInput
value={filter.label || ''}
onChange={setFilterLabel}
placeholder={getPlaceholder(filter.input.query)}
inputRef={inputRef}
onSubmit={() => setPopoverOpen(false)}
/>
</EuiPopover>
);
};
export const QueryInput = ({
value,
onChange,
indexPattern,
isInvalid,
onSubmit,
}: {
value: Query;
onChange: (input: Query) => void;
indexPattern: IndexPattern;
isInvalid: boolean;
onSubmit: () => void;
}) => {
const [inputValue, setInputValue] = useState(value);
React.useEffect(() => {
setInputValue(value);
}, [value, setInputValue]);
useDebounce(() => onChange(inputValue), 256, [inputValue]);
const handleInputChange = (input: Query) => {
setInputValue(input);
};
return (
<QueryStringInput
size="s"
isInvalid={isInvalid}
bubbleSubmitEvent={false}
indexPatterns={[indexPattern]}
query={inputValue}
onChange={handleInputChange}
onSubmit={() => {
if (inputValue.query) {
onSubmit();
}
}}
placeholder={
inputValue.language === 'kuery'
? i18n.translate('xpack.lens.indexPattern.filters.queryPlaceholderKql', {
defaultMessage: '{example}',
values: { example: 'method : "GET" or status : "404"' },
})
: i18n.translate('xpack.lens.indexPattern.filters.queryPlaceholderLucene', {
defaultMessage: '{example}',
values: { example: 'method:GET OR status:404' },
})
}
languageSwitcherPopoverAnchorPosition="rightDown"
/>
);
};
export const LabelInput = ({
value,
onChange,
placeholder,
inputRef,
onSubmit,
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
inputRef: React.MutableRefObject<HTMLInputElement | undefined>;
onSubmit: () => void;
}) => {
const [inputValue, setInputValue] = useState(value);
React.useEffect(() => {
setInputValue(value);
}, [value, setInputValue]);
useDebounce(() => onChange(inputValue), 256, [inputValue]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = String(e.target.value);
setInputValue(val);
};
return (
<EuiFieldText
data-test-subj="indexPattern-filters-label"
value={inputValue}
onChange={handleInputChange}
fullWidth
placeholder={placeholder}
inputRef={(node) => {
if (node) {
inputRef.current = node;
}
}}
onKeyDown={({ key }: React.KeyboardEvent<HTMLInputElement>) => {
if (keys.ENTER === key) {
onSubmit();
}
}}
prepend={i18n.translate('xpack.lens.indexPattern.filters.label', {
defaultMessage: 'Label',
})}
/>
);
};

View file

@ -0,0 +1,6 @@
.lnsFiltersOperation__popoverButton {
@include euiTextBreakWord;
@include euiFontSizeS;
min-height: $euiSizeXL;
width: 100%;
}

View file

@ -0,0 +1,284 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks';
import { FiltersIndexPatternColumn } from '.';
import { filtersOperation } from '../index';
import { IndexPatternPrivateState } from '../../../types';
import { FilterPopover } from './filter_popover';
const defaultProps = {
storage: {} as IStorageWrapper,
uiSettings: {} as IUiSettingsClient,
savedObjectsClient: {} as SavedObjectsClientContract,
dateRange: { fromDate: 'now-1d', toDate: 'now' },
data: dataPluginMock.createStartContract(),
http: {} as HttpSetup,
};
// mocking random id generator function
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
...original,
htmlIdGenerator: (fn: unknown) => {
let counter = 0;
return () => counter++;
},
};
});
describe('filters', () => {
let state: IndexPatternPrivateState;
const InlineOptions = filtersOperation.paramEditor!;
beforeEach(() => {
state = {
indexPatternRefs: [],
indexPatterns: {},
existingFields: {},
currentIndexPatternId: '1',
isFirstExistenceFetch: false,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
label: 'Custom query',
dataType: 'document',
operationType: 'filters',
scale: 'ordinal',
isBucketed: true,
sourceField: 'Records',
params: {
filters: [
{
input: { query: 'bytes >= 1', language: 'kuery' },
label: 'More than one',
},
{
input: { query: 'src : 2', language: 'kuery' },
label: '',
},
],
},
},
col2: {
label: 'Count',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
},
},
},
},
};
});
describe('toEsAggsConfig', () => {
it('should reflect params correctly', () => {
const esAggsConfig = filtersOperation.toEsAggsConfig(
state.layers.first.columns.col1 as FiltersIndexPatternColumn,
'col1',
state.indexPatterns['1']
);
expect(esAggsConfig).toEqual(
expect.objectContaining({
params: expect.objectContaining({
filters: [
{
input: { query: 'bytes >= 1', language: 'kuery' },
label: 'More than one',
},
{
input: { query: 'src : 2', language: 'kuery' },
label: '',
},
],
}),
})
);
});
});
describe('getPossibleOperationForField', () => {
it('should return operation with the right type for document', () => {
expect(
filtersOperation.getPossibleOperationForField({
aggregatable: true,
searchable: true,
name: 'test',
displayName: 'test',
type: 'document',
})
).toEqual({
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
});
});
it('should not return operation if field type is not document', () => {
expect(
filtersOperation.getPossibleOperationForField({
aggregatable: false,
searchable: true,
name: 'test',
displayName: 'test',
type: 'string',
})
).toEqual(undefined);
});
});
describe('popover param editor', () => {
// @ts-expect-error
window['__react-beautiful-dnd-disable-dev-warnings'] = true; // issue with enzyme & react-beautiful-dnd throwing errors: https://github.com/atlassian/react-beautiful-dnd/issues/1593
jest.mock('../../../../../../../../src/plugins/data/public', () => ({
QueryStringInput: () => {
return 'QueryStringInput';
},
}));
it('should update state when changing a filter', () => {
const setStateSpy = jest.fn();
const instance = mount(
<InlineOptions
{...defaultProps}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as FiltersIndexPatternColumn}
layerId="first"
/>
);
act(() => {
instance.find(FilterPopover).first().prop('setFilter')!({
input: {
query: 'dest : 5',
language: 'lucene',
},
label: 'Dest5',
id: 0,
});
});
instance.update();
expect(setStateSpy).toHaveBeenCalledWith({
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
...state.layers.first.columns.col1,
params: {
filters: [
{
input: {
query: 'dest : 5',
language: 'lucene',
},
label: 'Dest5',
},
{
input: {
language: 'kuery',
query: 'src : 2',
},
label: '',
},
],
},
},
},
},
},
});
});
describe('Modify custom query', () => {
it('should correctly show existing filters ', () => {
const setStateSpy = jest.fn();
const instance = mount(
<InlineOptions
{...defaultProps}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as FiltersIndexPatternColumn}
layerId="first"
/>
);
expect(
instance
.find('[data-test-subj="indexPattern-filters-existingFilterContainer"]')
.at(0)
.text()
).toEqual('More than one');
expect(
instance
.find('[data-test-subj="indexPattern-filters-existingFilterContainer"]')
.at(2)
.text()
).toEqual('src : 2');
});
it('should remove custom query', () => {
const setStateSpy = jest.fn();
const instance = mount(
<InlineOptions
{...defaultProps}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as FiltersIndexPatternColumn}
layerId="first"
/>
);
instance
.find('[data-test-subj="indexPattern-filters-existingFilterDelete"]')
.at(2)
.simulate('click');
expect(setStateSpy).toHaveBeenCalledWith({
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
...state.layers.first.columns.col1,
params: {
filters: [
{
input: {
language: 'kuery',
query: 'bytes >= 1',
},
label: 'More than one',
},
],
},
},
},
},
},
});
});
});
});
});

View file

@ -0,0 +1,341 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './filters.scss';
import React, { MouseEventHandler, useState } from 'react';
import { omit } from 'lodash';
import { i18n } from '@kbn/i18n';
import {
EuiDragDropContext,
EuiDraggable,
EuiDroppable,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
euiDragDropReorder,
EuiButtonIcon,
EuiButtonEmpty,
EuiIcon,
EuiFormRow,
EuiLink,
htmlIdGenerator,
} from '@elastic/eui';
import { updateColumnParam } from '../../../state_helpers';
import { OperationDefinition } from '../index';
import { FieldBasedIndexPatternColumn } from '../column_types';
import { FilterPopover } from './filter_popover';
import { IndexPattern } from '../../../types';
import { Query, esKuery, esQuery } from '../../../../../../../../src/plugins/data/public';
const generateId = htmlIdGenerator();
// references types from src/plugins/data/common/search/aggs/buckets/filters.ts
export interface Filter {
input: Query;
label: string;
}
export interface FilterValue {
input: Query;
label: string;
id: string;
}
const customQueryLabel = i18n.translate('xpack.lens.indexPattern.customQuery', {
defaultMessage: 'Custom query',
});
export const defaultLabel = i18n.translate('xpack.lens.indexPattern.filters.label.placeholder', {
defaultMessage: 'All records',
});
// to do: get the language from uiSettings
const defaultFilter: Filter = {
input: {
query: '',
language: 'kuery',
},
label: '',
};
export const isQueryValid = (input: Query, indexPattern: IndexPattern) => {
try {
if (input.language === 'kuery') {
esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(input.query), indexPattern);
} else {
esQuery.luceneStringToDsl(input.query);
}
return true;
} catch (e) {
return false;
}
};
interface DraggableLocation {
droppableId: string;
index: number;
}
export interface FiltersIndexPatternColumn extends FieldBasedIndexPatternColumn {
operationType: 'filters';
params: {
filters: Filter[];
};
}
export const filtersOperation: OperationDefinition<FiltersIndexPatternColumn> = {
type: 'filters',
displayName: customQueryLabel,
priority: 3, // Higher than any metric
getPossibleOperationForField: ({ type }) => {
if (type === 'document') {
return {
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
};
}
},
isTransferable: () => false,
onFieldChange: (oldColumn, indexPattern, field) => oldColumn,
buildColumn({ suggestedPriority, field, previousColumn }) {
let params = { filters: [defaultFilter] };
if (previousColumn?.operationType === 'terms') {
params = {
filters: [
{
label: '',
input: {
query: `${previousColumn.sourceField} : *`,
language: 'kuery',
},
},
],
};
}
return {
label: customQueryLabel,
dataType: 'string',
operationType: 'filters',
scale: 'ordinal',
suggestedPriority,
isBucketed: true,
sourceField: field.name,
params,
};
},
toEsAggsConfig: (column, columnId, indexPattern) => {
const validFilters = column.params.filters?.filter((f: Filter) =>
isQueryValid(f.input, indexPattern)
);
return {
id: columnId,
enabled: true,
type: 'filters',
schema: 'segment',
params: {
filters: validFilters?.length > 0 ? validFilters : [defaultFilter],
},
};
},
paramEditor: ({ state, setState, currentColumn, layerId, data }) => {
const indexPattern = state.indexPatterns[state.layers[layerId].indexPatternId];
const filters = currentColumn.params.filters;
const setFilters = (newFilters: Filter[]) =>
setState(
updateColumnParam({
state,
layerId,
currentColumn,
paramName: 'filters',
value: newFilters,
})
);
return (
<EuiFormRow>
<FilterList
filters={filters}
setFilters={setFilters}
indexPattern={indexPattern}
defaultQuery={defaultFilter}
/>
</EuiFormRow>
);
},
};
export const FilterList = ({
filters,
setFilters,
indexPattern,
defaultQuery,
}: {
filters: Filter[];
setFilters: Function;
indexPattern: IndexPattern;
defaultQuery: Filter;
}) => {
const [isOpenByCreation, setIsOpenByCreation] = useState(false);
const [localFilters, setLocalFilters] = useState(() =>
filters.map((filter) => ({ ...filter, id: generateId() }))
);
const updateFilters = (updatedFilters: FilterValue[]) => {
// do not set internal id parameter into saved object
setFilters(updatedFilters.map((filter) => omit(filter, 'id')));
setLocalFilters(updatedFilters);
};
const onAddFilter = () =>
updateFilters([
...localFilters,
{
...defaultQuery,
id: generateId(),
},
]);
const onRemoveFilter = (id: string) =>
updateFilters(localFilters.filter((filter) => filter.id !== id));
const onChangeValue = (id: string, query: Query, label: string) =>
updateFilters(
localFilters.map((filter) =>
filter.id === id
? {
...filter,
input: query,
label,
}
: filter
)
);
const onDragEnd = ({
source,
destination,
}: {
source?: DraggableLocation;
destination?: DraggableLocation;
}) => {
if (source && destination) {
const items = euiDragDropReorder(localFilters, source.index, destination.index);
updateFilters(items);
}
};
return (
<>
<EuiDragDropContext onDragEnd={onDragEnd} onDragStart={() => setIsOpenByCreation(false)}>
<EuiDroppable droppableId="FILTERS_DROPPABLE_AREA" spacing="s">
{localFilters?.map((filter: FilterValue, idx: number) => {
const { input, label, id } = filter;
const queryIsValid = isQueryValid(input, indexPattern);
return (
<EuiDraggable
spacing="m"
key={id}
index={idx}
draggableId={id}
disableInteractiveElementBlocking
>
{(provided) => (
<EuiPanel paddingSize="none">
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>{/* Empty for spacing */}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon
size="s"
color={queryIsValid ? 'subdued' : 'danger'}
type={queryIsValid ? 'grab' : 'alert'}
title={
queryIsValid
? i18n.translate('xpack.lens.indexPattern.filters.dragToReorder', {
defaultMessage: 'Drag to reorder',
})
: i18n.translate('xpack.lens.indexPattern.filters.isInvalid', {
defaultMessage: 'This query is invalid',
})
}
/>
</EuiFlexItem>
<EuiFlexItem
grow={true}
data-test-subj="indexPattern-filters-existingFilterContainer"
>
<FilterPopover
isOpenByCreation={idx === localFilters.length - 1 && isOpenByCreation}
setIsOpenByCreation={setIsOpenByCreation}
indexPattern={indexPattern}
filter={filter}
Button={({ onClick }: { onClick: MouseEventHandler }) => (
<EuiLink
className="lnsFiltersOperation__popoverButton"
data-test-subj="indexPattern-filters-existingFilterTrigger"
onClick={onClick}
color={queryIsValid ? 'text' : 'danger'}
title={i18n.translate('xpack.lens.indexPattern.filters.clickToEdit', {
defaultMessage: 'Click to edit',
})}
>
{label || input.query || defaultLabel}
</EuiLink>
)}
setFilter={(f: FilterValue) => {
onChangeValue(f.id, f.input, f.label);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconSize="s"
iconType="cross"
color="danger"
data-test-subj="indexPattern-filters-existingFilterDelete"
onClick={() => {
onRemoveFilter(filter.id);
}}
aria-label={i18n.translate(
'xpack.lens.indexPattern.filters.removeCustomQuery',
{
defaultMessage: 'Remove custom query',
}
)}
title={i18n.translate('xpack.lens.indexPattern.filters.remove', {
defaultMessage: 'Remove',
})}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
)}
</EuiDraggable>
);
})}
</EuiDroppable>
</EuiDragDropContext>
<EuiButtonEmpty
size="xs"
iconType="plusInCircle"
onClick={() => {
onAddFilter();
setIsOpenByCreation(true);
}}
>
{i18n.translate('xpack.lens.indexPattern.filters.addCustomQuery', {
defaultMessage: 'Add a custom query',
})}
</EuiButtonEmpty>
</>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './filters';

View file

@ -6,11 +6,21 @@
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { termsOperation } from './terms';
import { cardinalityOperation } from './cardinality';
import { minOperation, averageOperation, sumOperation, maxOperation } from './metrics';
import { dateHistogramOperation } from './date_histogram';
import { countOperation } from './count';
import { termsOperation, TermsIndexPatternColumn } from './terms';
import { filtersOperation, FiltersIndexPatternColumn } from './filters';
import { cardinalityOperation, CardinalityIndexPatternColumn } from './cardinality';
import {
minOperation,
MinIndexPatternColumn,
averageOperation,
AvgIndexPatternColumn,
sumOperation,
SumIndexPatternColumn,
maxOperation,
MaxIndexPatternColumn,
} from './metrics';
import { dateHistogramOperation, DateHistogramIndexPatternColumn } from './date_histogram';
import { countOperation, CountIndexPatternColumn } from './count';
import { DimensionPriority, StateSetter, OperationMetadata } from '../../../types';
import { BaseIndexPatternColumn } from './column_types';
import { IndexPatternPrivateState, IndexPattern, IndexPatternField } from '../../types';
@ -18,9 +28,10 @@ import { DateRange } from '../../../../common';
import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public';
// List of all operation definitions registered to this data source.
// If you want to implement a new operation, add it to this array and
// its type will get propagated to everything else
// If you want to implement a new operation, add the definition to this array and
// the column type to the `IndexPatternColumn` union type below.
const internalOperationDefinitions = [
filtersOperation,
termsOperation,
dateHistogramOperation,
minOperation,
@ -31,7 +42,24 @@ const internalOperationDefinitions = [
countOperation,
];
/**
* A union type of all available column types. If a column is of an unknown type somewhere
* withing the indexpattern data source it should be typed as `IndexPatternColumn` to make
* typeguards possible that consider all available column types.
*/
export type IndexPatternColumn =
| FiltersIndexPatternColumn
| TermsIndexPatternColumn
| DateHistogramIndexPatternColumn
| MinIndexPatternColumn
| MaxIndexPatternColumn
| AvgIndexPatternColumn
| CardinalityIndexPatternColumn
| SumIndexPatternColumn
| CountIndexPatternColumn;
export { termsOperation } from './terms';
export { filtersOperation } from './filters';
export { dateHistogramOperation } from './date_histogram';
export { minOperation, averageOperation, sumOperation, maxOperation } from './metrics';
export { countOperation } from './count';
@ -106,7 +134,12 @@ interface BaseBuildColumnArgs {
indexPattern: IndexPattern;
}
interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn>
/**
* Shape of an operation definition. If the type parameter of the definition
* indicates a field based column, `getPossibleOperationForField` has to be
* specified, otherwise `getPossibleOperationForDocument` has to be defined.
*/
export interface OperationDefinition<C extends BaseIndexPatternColumn>
extends BaseOperationDefinitionProps<C> {
/**
* Returns the meta data of the operation if applied to the given field. Undefined
@ -119,7 +152,7 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn>
buildColumn: (
arg: BaseBuildColumnArgs & {
field: IndexPatternField;
previousColumn?: C;
previousColumn?: IndexPatternColumn;
}
) => C;
/**
@ -141,29 +174,6 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn>
onFieldChange: (oldColumn: C, indexPattern: IndexPattern, field: IndexPatternField) => C;
}
/**
* Shape of an operation definition. If the type parameter of the definition
* indicates a field based column, `getPossibleOperationForField` has to be
* specified, otherwise `getPossibleOperationForDocument` has to be defined.
*/
export type OperationDefinition<C extends BaseIndexPatternColumn> = FieldBasedOperationDefinition<
C
>;
// Helper to to infer the column type out of the operation definition.
// This is done to avoid it to have to list out the column types along with
// the operation definition types
type ColumnFromOperationDefinition<D> = D extends OperationDefinition<infer C> ? C : never;
/**
* A union type of all available column types. If a column is of an unknown type somewhere
* withing the indexpattern data source it should be typed as `IndexPatternColumn` to make
* typeguards possible that consider all available column types.
*/
export type IndexPatternColumn = ColumnFromOperationDefinition<
typeof internalOperationDefinitions[number]
>;
/**
* A union type of all available operation types. The operation type is a unique id of an operation.
* Each column is assigned to exactly one operation type.
@ -174,7 +184,7 @@ export type OperationType = typeof internalOperationDefinitions[number]['type'];
* This is an operation definition of an unspecified column out of all possible
* column types.
*/
export type GenericOperationDefinition = FieldBasedOperationDefinition<IndexPatternColumn>;
export type GenericOperationDefinition = OperationDefinition<IndexPatternColumn>;
/**
* List of all available operation definitions

View file

@ -12,7 +12,7 @@ export interface IndexPattern {
id: string;
fields: IndexPatternField[];
title: string;
timeFieldName?: string | null;
timeFieldName?: string;
fieldFormatMap?: Record<
string,
{