diff --git a/packages/kbn-react-field/src/field_icon/__snapshots__/field_icon.test.tsx.snap b/packages/kbn-react-field/src/field_icon/__snapshots__/field_icon.test.tsx.snap index 0e9ae4ee2aaaa..cd81705dd3c19 100644 --- a/packages/kbn-react-field/src/field_icon/__snapshots__/field_icon.test.tsx.snap +++ b/packages/kbn-react-field/src/field_icon/__snapshots__/field_icon.test.tsx.snap @@ -195,6 +195,16 @@ exports[`FieldIcon renders known field types text is rendered 1`] = ` /> `; +exports[`FieldIcon renders known field types version is rendered 1`] = ` + +`; + exports[`FieldIcon renders with className if provided 1`] = ` { | '_source' | 'string' | string - | 'nested'; + | 'nested' + | 'version'; label?: string; scripted?: boolean; } @@ -54,6 +55,7 @@ export const typeToEuiIconMap: Partial> = { text: { iconType: 'tokenString' }, keyword: { iconType: 'tokenKeyword' }, nested: { iconType: 'tokenNested' }, + version: { iconType: 'tokenTag' }, }; /** diff --git a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts index 31f886daeb4cc..f88fd3b9a0157 100644 --- a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts +++ b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts @@ -103,9 +103,16 @@ export const setupValueSuggestionProvider = ( useTimeRange ?? core!.uiSettings.get(UI_SETTINGS.AUTOCOMPLETE_USE_TIMERANGE); const { title } = indexPattern; + const isVersionFieldType = field.type === 'string' && field.esTypes?.includes('version'); + if (field.type === 'boolean') { return [true, false]; - } else if (!shouldSuggestValues || !field.aggregatable || field.type !== 'string') { + } else if ( + !shouldSuggestValues || + !field.aggregatable || + field.type !== 'string' || + isVersionFieldType // suggestions don't work for version fields + ) { return []; } diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx index 726a4bf29a43c..35ac8f386946e 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx @@ -345,7 +345,7 @@ class FilterEditorUI extends Component { private renderParamsEditor() { const indexPattern = this.state.selectedIndexPattern; - if (!indexPattern || !this.state.selectedOperator) { + if (!indexPattern || !this.state.selectedOperator || !this.state.selectedField) { return ''; } diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts index ea172c2ccac35..7d433bb1f273b 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts @@ -8,6 +8,8 @@ import dateMath from '@elastic/datemath'; import { Filter, FieldFilter } from '@kbn/es-query'; +import { ES_FIELD_TYPES } from '@kbn/field-types'; +import isSemverValid from 'semver/functions/valid'; import { FILTER_OPERATORS, Operator } from './filter_operators'; import { isFilterable, IIndexPattern, IFieldType, IpAddress } from '../../../../../common'; @@ -27,12 +29,14 @@ export function getFilterableFields(indexPattern: IIndexPattern) { export function getOperatorOptions(field: IFieldType) { return FILTER_OPERATORS.filter((operator) => { - return !operator.fieldTypes || operator.fieldTypes.includes(field.type); + if (operator.field) return operator.field(field); + if (operator.fieldTypes) return operator.fieldTypes.includes(field.type); + return true; }); } -export function validateParams(params: any, type: string) { - switch (type) { +export function validateParams(params: any, field: IFieldType) { + switch (field.type) { case 'date': const moment = typeof params === 'string' ? dateMath.parse(params) : null; return Boolean(typeof params === 'string' && moment && moment.isValid()); @@ -42,6 +46,11 @@ export function validateParams(params: any, type: string) { } catch (e) { return false; } + case 'string': + if (field.esTypes?.includes(ES_FIELD_TYPES.VERSION)) { + return isSemverValid(params); + } + return true; default: return true; } @@ -58,19 +67,19 @@ export function isFilterValid( } switch (operator.type) { case 'phrase': - return validateParams(params, field.type); + return validateParams(params, field); case 'phrases': if (!Array.isArray(params) || !params.length) { return false; } - return params.every((phrase) => validateParams(phrase, field.type)); + return params.every((phrase) => validateParams(phrase, field)); case 'range': if (typeof params !== 'object') { return false; } return ( - (!params.from || validateParams(params.from, field.type)) && - (!params.to || validateParams(params.to, field.type)) + (!params.from || validateParams(params.from, field)) && + (!params.to || validateParams(params.to, field)) ); case 'exists': return true; diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_operators.ts b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_operators.ts index bc3f01aeb3c8f..0be9c200c8d82 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_operators.ts +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_operators.ts @@ -8,12 +8,24 @@ import { i18n } from '@kbn/i18n'; import { FILTERS } from '@kbn/es-query'; +import { ES_FIELD_TYPES } from '@kbn/field-types'; +import { IFieldType } from '../../../../../../data_views/common'; export interface Operator { message: string; type: FILTERS; negate: boolean; + + /** + * KbnFieldTypes applicable for operator + */ fieldTypes?: string[]; + + /** + * A filter predicate for a field, + * takes precedence over {@link fieldTypes} + */ + field?: (field: IFieldType) => boolean; } export const isOperator = { @@ -56,7 +68,14 @@ export const isBetweenOperator = { }), type: FILTERS.RANGE, negate: false, - fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], + field: (field: IFieldType) => { + if (['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'].includes(field.type)) + return true; + + if (field.type === 'string' && field.esTypes?.includes(ES_FIELD_TYPES.VERSION)) return true; + + return false; + }, }; export const isNotBetweenOperator = { @@ -65,7 +84,14 @@ export const isNotBetweenOperator = { }), type: FILTERS.RANGE, negate: true, - fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], + field: (field: IFieldType) => { + if (['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'].includes(field.type)) + return true; + + if (field.type === 'string' && field.esTypes?.includes(ES_FIELD_TYPES.VERSION)) return true; + + return false; + }, }; export const existsOperator = { diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_suggestor.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_suggestor.tsx index fb3106e6a8f06..ba39ee78dafa4 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_suggestor.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_suggestor.tsx @@ -16,7 +16,7 @@ import { UI_SETTINGS } from '../../../../common'; export interface PhraseSuggestorProps { kibana: KibanaReactContextValue; indexPattern: IIndexPattern; - field?: IFieldType; + field: IFieldType; timeRangeForSuggestionsOverride?: boolean; } @@ -54,7 +54,15 @@ export class PhraseSuggestorUI extends React.Com UI_SETTINGS.FILTERS_EDITOR_SUGGEST_VALUES ); const { field } = this.props; - return shouldSuggestValues && field && field.aggregatable && field.type === 'string'; + const isVersionFieldType = field?.esTypes?.includes('version'); + + return ( + shouldSuggestValues && + field && + field.aggregatable && + field.type === 'string' && + !isVersionFieldType // suggestions don't work for version fields + ); } protected onSearchChange = (value: string | number | boolean) => { diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx index 8690387fe61ed..32a713361dbd1 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx @@ -43,7 +43,7 @@ class PhraseValueInputUI extends PhraseSuggestorUI { })} value={this.props.value} onChange={this.props.onChange} - type={this.props.field ? this.props.field.type : 'string'} + field={this.props.field} /> )} diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx index 27642e62a9618..6561761dc623b 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx @@ -23,7 +23,7 @@ interface RangeParams { type RangeParamsPartial = Partial; interface Props { - field?: IFieldType; + field: IFieldType; value?: RangeParams; onChange: (params: RangeParamsPartial) => void; intl: InjectedIntl; @@ -32,7 +32,6 @@ interface Props { function RangeValueInputUI(props: Props) { const kibana = useKibana(); - const type = props.field ? props.field.type : 'string'; const tzConfig = kibana.services.uiSettings!.get('dateFormat:tz'); const formatDateChange = (value: string | number | boolean) => { @@ -69,7 +68,7 @@ function RangeValueInputUI(props: Props) { startControl={ { @@ -84,7 +83,7 @@ function RangeValueInputUI(props: Props) { endControl={ { diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx index b72743ae9d2d9..9b00c71472f37 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx @@ -11,10 +11,11 @@ import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; import { isEmpty } from 'lodash'; import React, { Component } from 'react'; import { validateParams } from './lib/filter_editor_utils'; +import { IFieldType } from '../../../../../data_views/common'; interface Props { value?: string | number; - type: string; + field: IFieldType; onChange: (value: string | number | boolean) => void; onBlur?: (value: string | number | boolean) => void; placeholder: string; @@ -27,8 +28,9 @@ interface Props { class ValueInputTypeUI extends Component { public render() { const value = this.props.value; + const type = this.props.field.type; let inputElement: React.ReactNode; - switch (this.props.type) { + switch (type) { case 'string': inputElement = ( { placeholder={this.props.placeholder} value={value} onChange={this.onChange} + isInvalid={!validateParams(value, this.props.field)} controlOnly={this.props.controlOnly} className={this.props.className} /> @@ -63,7 +66,7 @@ class ValueInputTypeUI extends Component { value={value} onChange={this.onChange} onBlur={this.onBlur} - isInvalid={!isEmpty(value) && !validateParams(value, this.props.type)} + isInvalid={!isEmpty(value) && !validateParams(value, this.props.field)} controlOnly={this.props.controlOnly} className={this.props.className} /> @@ -77,7 +80,7 @@ class ValueInputTypeUI extends Component { placeholder={this.props.placeholder} value={value} onChange={this.onChange} - isInvalid={!isEmpty(value) && !validateParams(value, this.props.type)} + isInvalid={!isEmpty(value) && !validateParams(value, this.props.field)} controlOnly={this.props.controlOnly} className={this.props.className} /> diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/get_field_type_name.ts b/src/plugins/discover/public/application/main/components/sidebar/lib/get_field_type_name.ts index f68395593bd8b..731f860058737 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/lib/get_field_type_name.ts +++ b/src/plugins/discover/public/application/main/components/sidebar/lib/get_field_type_name.ts @@ -64,6 +64,10 @@ export function getFieldTypeName(type: string) { return i18n.translate('discover.fieldNameIcons.nestedFieldAriaLabel', { defaultMessage: 'Nested field', }); + case 'version': + return i18n.translate('discover.fieldNameIcons.versionFieldAriaLabel', { + defaultMessage: 'Version field', + }); default: return i18n.translate('discover.fieldNameIcons.unknownFieldAriaLabel', { defaultMessage: 'Unknown field', diff --git a/test/functional/apps/discover/_filter_editor.ts b/test/functional/apps/discover/_filter_editor.ts index 8bcb4382bb3bf..0fb8837598904 100644 --- a/test/functional/apps/discover/_filter_editor.ts +++ b/test/functional/apps/discover/_filter_editor.ts @@ -56,6 +56,60 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.discover.getHitCount()).to.be('1'); }); }); + + describe('version fields', async () => { + const es = getService('es'); + const indexPatterns = getService('indexPatterns'); + const indexTitle = 'version-test'; + + before(async () => { + if (await es.indices.exists({ index: indexTitle })) { + await es.indices.delete({ index: indexTitle }); + } + + await es.indices.create({ + index: indexTitle, + body: { + mappings: { + properties: { + version: { + type: 'version', + }, + }, + }, + }, + }); + + await es.index({ + index: indexTitle, + body: { + version: '1.0.0', + }, + refresh: 'wait_for', + }); + + await es.index({ + index: indexTitle, + body: { + version: '2.0.0', + }, + refresh: 'wait_for', + }); + + await indexPatterns.create({ title: indexTitle }, { override: true }); + + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.selectIndexPattern(indexTitle); + }); + + it('should support range filter on version fields', async () => { + await filterBar.addFilter('version', 'is between', '2.0.0', '3.0.0'); + expect(await filterBar.hasFilter('version', '2.0.0 to 3.0.0')).to.be(true); + await retry.try(async function () { + expect(await PageObjects.discover.getHitCount()).to.be('1'); + }); + }); + }); }); }); }