{
}
role="textbox"
data-test-subj={this.props.dataTestSubj || 'queryInput'}
+ isInvalid={this.props.isInvalid}
>
{this.getQueryString()}
diff --git a/src/plugins/data/public/ui/typeahead/constants.ts b/src/plugins/data/public/ui/typeahead/constants.ts
index 08f9bd23e16f..0e28891a1453 100644
--- a/src/plugins/data/public/ui/typeahead/constants.ts
+++ b/src/plugins/data/public/ui/typeahead/constants.ts
@@ -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;
diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx
index dc7c55374f1d..50ed9e9542d3 100644
--- a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx
+++ b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx
@@ -154,6 +154,7 @@ export class SuggestionsComponent extends Component
{
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}`}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss
index 5b968abd0c06..954fbfadf159 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss
@@ -1,3 +1,2 @@
@import 'config_panel';
-@import 'dimension_popover';
@import 'layer_panel';
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss
index 62bc6d7ed7cc..ab53ff983ca2 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss
@@ -43,6 +43,14 @@
min-height: $euiSizeXXL;
}
+.lnsLayerPanel__anchor {
+ width: 100%;
+}
+
+.lnsLayerPanel__dndGrab {
+ padding: $euiSizeS;
+}
+
.lnsLayerPanel__styleEditor {
width: $euiSize * 30;
padding: $euiSizeS;
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_dimension_popover.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.scss
similarity index 51%
rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_dimension_popover.scss
rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.scss
index 691cda9ff0d7..98036c7f31bd 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_dimension_popover.scss
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.scss
@@ -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;
+}
+
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx
index 8d31e1bcc2e6..a90bd8122d18 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx
@@ -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({
= {
+ 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 = {
+ 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 (
<>
@@ -73,34 +104,14 @@ export function BucketNestingEditor({
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx
index 038b51b92228..d5f0110f071f 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx
@@ -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) {
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
index e2ca93350484..3b3750cf7c56 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
@@ -263,6 +263,7 @@ export function getIndexPatternDatasource({
data,
savedObjects: core.savedObjects,
docLinks: core.docLinks,
+ http: core.http,
}}
>
({
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx
index 4e081da2c6dc..bb1aef856de7 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx
@@ -49,7 +49,11 @@ export const countOperation: OperationDefinition = {
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) => ({
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.scss b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.scss
new file mode 100644
index 000000000000..6838812e4b99
--- /dev/null
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.scss
@@ -0,0 +1,3 @@
+.lnsIndexPatternDimensionEditor__filtersEditor {
+ width: $euiSize * 60;
+}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx
new file mode 100644
index 000000000000..4d4b4018d75a
--- /dev/null
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx
@@ -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 }) => (
+ trigger
+ ),
+ 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(
+
+ );
+ 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();
+ 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();
+ instance.find(LabelInput).prop('onChange')!('Modified label');
+ expect(setFilter).toHaveBeenCalledWith({
+ input: {
+ language: 'kuery',
+ query: 'bytes >= 1',
+ },
+ label: 'Modified label',
+ id: '1',
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx
new file mode 100644
index 000000000000..cdfa19f53a13
--- /dev/null
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx
@@ -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();
+
+ 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 (
+ {
+ setPopoverOpen(false);
+ }}
+ button={
+
+ );
+};
+
+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 (
+ {
+ 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;
+ onSubmit: () => void;
+}) => {
+ const [inputValue, setInputValue] = useState(value);
+
+ React.useEffect(() => {
+ setInputValue(value);
+ }, [value, setInputValue]);
+
+ useDebounce(() => onChange(inputValue), 256, [inputValue]);
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const val = String(e.target.value);
+ setInputValue(val);
+ };
+
+ return (
+ {
+ if (node) {
+ inputRef.current = node;
+ }
+ }}
+ onKeyDown={({ key }: React.KeyboardEvent) => {
+ if (keys.ENTER === key) {
+ onSubmit();
+ }
+ }}
+ prepend={i18n.translate('xpack.lens.indexPattern.filters.label', {
+ defaultMessage: 'Label',
+ })}
+ />
+ );
+};
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.scss b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.scss
new file mode 100644
index 000000000000..37cf1ee58276
--- /dev/null
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.scss
@@ -0,0 +1,6 @@
+.lnsFiltersOperation__popoverButton {
+ @include euiTextBreakWord;
+ @include euiFontSizeS;
+ min-height: $euiSizeXL;
+ width: 100%;
+}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx
new file mode 100644
index 000000000000..13854d1ca91d
--- /dev/null
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx
@@ -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(
+
+ );
+
+ 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(
+
+ );
+ 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(
+
+ );
+
+ 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',
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ });
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx
new file mode 100644
index 000000000000..c740f8466e1b
--- /dev/null
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx
@@ -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 = {
+ 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 (
+
+
+
+ );
+ },
+};
+
+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 (
+ <>
+ setIsOpenByCreation(false)}>
+
+ {localFilters?.map((filter: FilterValue, idx: number) => {
+ const { input, label, id } = filter;
+ const queryIsValid = isQueryValid(input, indexPattern);
+
+ return (
+
+ {(provided) => (
+
+
+ {/* Empty for spacing */}
+
+
+
+
+ (
+
+ {label || input.query || defaultLabel}
+
+ )}
+ setFilter={(f: FilterValue) => {
+ onChangeValue(f.id, f.input, f.label);
+ }}
+ />
+
+
+ {
+ 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',
+ })}
+ />
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+ {
+ onAddFilter();
+ setIsOpenByCreation(true);
+ }}
+ >
+ {i18n.translate('xpack.lens.indexPattern.filters.addCustomQuery', {
+ defaultMessage: 'Add a custom query',
+ })}
+
+ >
+ );
+};
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/index.tsx
new file mode 100644
index 000000000000..30577bfde8c9
--- /dev/null
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/index.tsx
@@ -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';
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts
index 8ef53c1e0b42..3dd8b659fe0a 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts
@@ -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
+/**
+ * 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
extends BaseOperationDefinitionProps {
/**
* Returns the meta data of the operation if applied to the given field. Undefined
@@ -119,7 +152,7 @@ interface FieldBasedOperationDefinition
buildColumn: (
arg: BaseBuildColumnArgs & {
field: IndexPatternField;
- previousColumn?: C;
+ previousColumn?: IndexPatternColumn;
}
) => C;
/**
@@ -141,29 +174,6 @@ interface FieldBasedOperationDefinition
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 = 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 extends OperationDefinition ? 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;
+export type GenericOperationDefinition = OperationDefinition;
/**
* List of all available operation definitions
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts
index 21ca41234fdf..b691c5b5c4c4 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts
@@ -12,7 +12,7 @@ export interface IndexPattern {
id: string;
fields: IndexPatternField[];
title: string;
- timeFieldName?: string | null;
+ timeFieldName?: string;
fieldFormatMap?: Record<
string,
{