mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Lens] Filters aggregation (#75635)
This commit is contained in:
parent
65abdfffee
commit
0c678ebada
25 changed files with 1050 additions and 79 deletions
|
@ -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">>
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}`}
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
@import 'config_panel';
|
||||
@import 'dimension_popover';
|
||||
@import 'layer_panel';
|
||||
|
|
|
@ -43,6 +43,14 @@
|
|||
min-height: $euiSizeXXL;
|
||||
}
|
||||
|
||||
.lnsLayerPanel__anchor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lnsLayerPanel__dndGrab {
|
||||
padding: $euiSizeS;
|
||||
}
|
||||
|
||||
.lnsLayerPanel__styleEditor {
|
||||
width: $euiSize * 30;
|
||||
padding: $euiSizeS;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -263,6 +263,7 @@ export function getIndexPatternDatasource({
|
|||
data,
|
||||
savedObjects: core.savedObjects,
|
||||
docLinks: core.docLinks,
|
||||
http: core.http,
|
||||
}}
|
||||
>
|
||||
<IndexPatternDimensionEditor
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.lnsIndexPatternDimensionEditor__filtersEditor {
|
||||
width: $euiSize * 60;
|
||||
}
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,6 @@
|
|||
.lnsFiltersOperation__popoverButton {
|
||||
@include euiTextBreakWord;
|
||||
@include euiFontSizeS;
|
||||
min-height: $euiSizeXL;
|
||||
width: 100%;
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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
|
||||
|
|
|
@ -12,7 +12,7 @@ export interface IndexPattern {
|
|||
id: string;
|
||||
fields: IndexPatternField[];
|
||||
title: string;
|
||||
timeFieldName?: string | null;
|
||||
timeFieldName?: string;
|
||||
fieldFormatMap?: Record<
|
||||
string,
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue