diff --git a/docs/maps/maps-aggregations.asciidoc b/docs/maps/maps-aggregations.asciidoc index b953db8858c90..923b6cc1d6649 100644 --- a/docs/maps/maps-aggregations.asciidoc +++ b/docs/maps/maps-aggregations.asciidoc @@ -36,8 +36,6 @@ To enable most recent entities, click "Show most recent documents by entity" and . Set *Entity* to the field that identifies entities in your documents. This field will be used in the terms aggregation to group your documents into entity buckets. -. Set *Time* to the date field that puts your documents in chronological order. -This field will be used to sort your documents in the top hits aggregation. . Set *Documents per entity* to configure the maximum number of documents accumulated per entity. [role="xpack"] diff --git a/x-pack/legacy/plugins/maps/common/constants.js b/x-pack/legacy/plugins/maps/common/constants.js index 5af3f8cfc0714..33ad39adc5d1f 100644 --- a/x-pack/legacy/plugins/maps/common/constants.js +++ b/x-pack/legacy/plugins/maps/common/constants.js @@ -39,6 +39,11 @@ export const LAYER_TYPE = { HEATMAP: 'HEATMAP' }; +export const SORT_ORDER = { + ASC: 'asc', + DESC: 'desc', +}; + export const EMS_TMS = 'EMS_TMS'; export const EMS_FILE = 'EMS_FILE'; export const ES_GEO_GRID = 'ES_GEO_GRID'; diff --git a/x-pack/legacy/plugins/maps/common/migrations/top_hits_time_to_sort.js b/x-pack/legacy/plugins/maps/common/migrations/top_hits_time_to_sort.js new file mode 100644 index 0000000000000..7a72533005f47 --- /dev/null +++ b/x-pack/legacy/plugins/maps/common/migrations/top_hits_time_to_sort.js @@ -0,0 +1,35 @@ +/* + * 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 _ from 'lodash'; +import { ES_SEARCH, SORT_ORDER } from '../constants'; + +function isEsDocumentSource(layerDescriptor) { + const sourceType = _.get(layerDescriptor, 'sourceDescriptor.type'); + return sourceType === ES_SEARCH; +} + +export function topHitsTimeToSort({ attributes }) { + if (!attributes.layerListJSON) { + return attributes; + } + + const layerList = JSON.parse(attributes.layerListJSON); + layerList.forEach((layerDescriptor) => { + if (isEsDocumentSource(layerDescriptor)) { + if (_.has(layerDescriptor, 'sourceDescriptor.topHitsTimeField')) { + layerDescriptor.sourceDescriptor.sortField = layerDescriptor.sourceDescriptor.topHitsTimeField; + layerDescriptor.sourceDescriptor.sortOrder = SORT_ORDER.DESC; + delete layerDescriptor.sourceDescriptor.topHitsTimeField; + } + } + }); + + return { + ...attributes, + layerListJSON: JSON.stringify(layerList), + }; +} diff --git a/x-pack/legacy/plugins/maps/common/migrations/top_hits_time_to_sort.test.js b/x-pack/legacy/plugins/maps/common/migrations/top_hits_time_to_sort.test.js new file mode 100644 index 0000000000000..09913e83cbb43 --- /dev/null +++ b/x-pack/legacy/plugins/maps/common/migrations/top_hits_time_to_sort.test.js @@ -0,0 +1,60 @@ +/* + * 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. + */ + +/* eslint max-len: 0 */ + +import { topHitsTimeToSort } from './top_hits_time_to_sort'; + +describe('topHitsTimeToSort', () => { + + test('Should handle missing layerListJSON attribute', () => { + const attributes = { + title: 'my map', + }; + expect(topHitsTimeToSort({ attributes })).toEqual({ + title: 'my map', + }); + }); + + test('Should move topHitsTimeField to sortField for ES documents sources', () => { + const layerListJSON = JSON.stringify([ + { + sourceDescriptor: { + type: 'ES_SEARCH', + topHitsSplitField: 'gpsId', + topHitsTimeField: '@timestamp', + } + } + ]); + const attributes = { + title: 'my map', + layerListJSON + }; + expect(topHitsTimeToSort({ attributes })).toEqual({ + title: 'my map', + layerListJSON: '[{\"sourceDescriptor\":{\"type\":\"ES_SEARCH\",\"topHitsSplitField\":\"gpsId\",\"sortField\":\"@timestamp\",\"sortOrder\":\"desc\"}}]', + }); + }); + + test('Should handle ES documents sources without topHitsTimeField', () => { + const layerListJSON = JSON.stringify([ + { + sourceDescriptor: { + type: 'ES_SEARCH', + topHitsSplitField: 'gpsId', + } + } + ]); + const attributes = { + title: 'my map', + layerListJSON + }; + expect(topHitsTimeToSort({ attributes })).toEqual({ + title: 'my map', + layerListJSON, + }); + }); +}); diff --git a/x-pack/legacy/plugins/maps/migrations.js b/x-pack/legacy/plugins/maps/migrations.js index 6a97636e679c4..cdec1f25ce247 100644 --- a/x-pack/legacy/plugins/maps/migrations.js +++ b/x-pack/legacy/plugins/maps/migrations.js @@ -6,6 +6,7 @@ import { extractReferences } from './common/migrations/references'; import { emsRasterTileToEmsVectorTile } from './common/migrations/ems_raster_tile_to_ems_vector_tile'; +import { topHitsTimeToSort } from './common/migrations/top_hits_time_to_sort'; export const migrations = { 'map': { @@ -21,6 +22,14 @@ export const migrations = { '7.4.0': (doc) => { const attributes = emsRasterTileToEmsVectorTile(doc); + return { + ...doc, + attributes, + }; + }, + '7.5.0': (doc) => { + const attributes = topHitsTimeToSort(doc); + return { ...doc, attributes, diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap new file mode 100644 index 0000000000000..c0736a54e55d2 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap @@ -0,0 +1,292 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should enable sort order select when sort field provided 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`should render top hits form when useTopHits is true 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`should render update source editor 1`] = ` + + + + + + + + + + + + + + + + + + + +`; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js index 071e231bb6e2d..53762a1f27d27 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -14,7 +14,7 @@ import { SearchSource } from '../../../kibana_services'; import { hitsToGeoJson } from '../../../elasticsearch_geo_utils'; import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; -import { ES_SEARCH, ES_GEO_FIELD_TYPE, ES_SIZE_LIMIT } from '../../../../common/constants'; +import { ES_SEARCH, ES_GEO_FIELD_TYPE, ES_SIZE_LIMIT, SORT_ORDER } from '../../../../common/constants'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { ESTooltipProperty } from '../../tooltips/es_tooltip_property'; @@ -56,9 +56,10 @@ export class ESSearchSource extends AbstractESSource { geoField: descriptor.geoField, filterByMapBounds: _.get(descriptor, 'filterByMapBounds', DEFAULT_FILTER_BY_MAP_BOUNDS), tooltipProperties: _.get(descriptor, 'tooltipProperties', []), + sortField: _.get(descriptor, 'sortField', ''), + sortOrder: _.get(descriptor, 'sortOrder', SORT_ORDER.DESC), useTopHits: _.get(descriptor, 'useTopHits', false), topHitsSplitField: descriptor.topHitsSplitField, - topHitsTimeField: descriptor.topHitsTimeField, topHitsSize: _.get(descriptor, 'topHitsSize', 1), }, inspectorAdapters); } @@ -70,9 +71,10 @@ export class ESSearchSource extends AbstractESSource { onChange={onChange} filterByMapBounds={this._descriptor.filterByMapBounds} tooltipProperties={this._descriptor.tooltipProperties} + sortField={this._descriptor.sortField} + sortOrder={this._descriptor.sortOrder} useTopHits={this._descriptor.useTopHits} topHitsSplitField={this._descriptor.topHitsSplitField} - topHitsTimeField={this._descriptor.topHitsTimeField} topHitsSize={this._descriptor.topHitsSize} /> ); @@ -135,10 +137,24 @@ export class ESSearchSource extends AbstractESSource { ]; } + // Returns sort content for an Elasticsearch search body + _buildEsSort() { + const { + sortField, + sortOrder, + } = this._descriptor; + return [ + { + [sortField]: { + order: sortOrder + } + } + ]; + } + async _getTopHits(layerName, searchFilters, registerCancelCallback) { const { topHitsSplitField, - topHitsTimeField, topHitsSize, } = this._descriptor; @@ -158,7 +174,7 @@ export class ESSearchSource extends AbstractESSource { }); const searchSource = await this._makeSearchSource(searchFilters, 0); - searchSource.setField('aggs', { + const aggs = { entitySplit: { terms: { field: topHitsSplitField, @@ -167,13 +183,6 @@ export class ESSearchSource extends AbstractESSource { aggs: { entityHits: { top_hits: { - sort: [ - { - [topHitsTimeField]: { - order: 'desc' - } - } - ], _source: { includes: searchFilters.fieldNames }, @@ -183,7 +192,11 @@ export class ESSearchSource extends AbstractESSource { } } } - }); + }; + if (this._hasSort()) { + aggs.entitySplit.aggs.entityHits.top_hits.sort = this._buildEsSort(); + } + searchSource.setField('aggs', aggs); const resp = await this._runEsQuery(layerName, searchSource, registerCancelCallback, 'Elasticsearch document top hits request'); @@ -193,7 +206,7 @@ export class ESSearchSource extends AbstractESSource { entityBuckets.forEach(entityBucket => { const total = _.get(entityBucket, 'entityHits.hits.total', 0); const hits = _.get(entityBucket, 'entityHits.hits.hits', []); - // Reverse hits list so they are drawn from oldest to newest (per entity) so newest events are on top + // Reverse hits list so top documents by sort are drawn on top allHits.push(...hits.reverse()); if (total > hits.length) { hasTrimmedResults = true; @@ -218,10 +231,14 @@ export class ESSearchSource extends AbstractESSource { // By setting "fields", SearchSource removes all of defaults searchSource.setField('fields', searchFilters.fieldNames); + if (this._hasSort()) { + searchSource.setField('sort', this._buildEsSort()); + } + const resp = await this._runEsQuery(layerName, searchSource, registerCancelCallback, 'Elasticsearch document request'); return { - hits: resp.hits.hits, + hits: resp.hits.hits.reverse(), // Reverse hits so top documents by sort are drawn on top meta: { areResultsTrimmed: resp.hits.total > resp.hits.hits.length } @@ -229,8 +246,13 @@ export class ESSearchSource extends AbstractESSource { } _isTopHits() { - const { useTopHits, topHitsSplitField, topHitsTimeField } = this._descriptor; - return !!(useTopHits && topHitsSplitField && topHitsTimeField); + const { useTopHits, topHitsSplitField } = this._descriptor; + return !!(useTopHits && topHitsSplitField); + } + + _hasSort() { + const { sortField, sortOrder } = this._descriptor; + return !!sortField && !!sortOrder; } async getGeoJsonWithMeta(layerName, searchFilters, registerCancelCallback) { @@ -406,9 +428,10 @@ export class ESSearchSource extends AbstractESSource { getSyncMeta() { return { + sortField: this._descriptor.sortField, + sortOrder: this._descriptor.sortOrder, useTopHits: this._descriptor.useTopHits, topHitsSplitField: this._descriptor.topHitsSplitField, - topHitsTimeField: this._descriptor.topHitsTimeField, topHitsSize: this._descriptor.topHitsSize, }; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js index ff319fd3d7e97..37c955c982168 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js @@ -6,7 +6,13 @@ import React, { Fragment, Component } from 'react'; import PropTypes from 'prop-types'; -import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { + EuiFormRow, + EuiSwitch, + EuiFlexGroup, + EuiFlexItem, + EuiSelect, +} from '@elastic/eui'; import { SingleFieldSelect } from '../../../components/single_field_select'; import { TooltipSelector } from '../../../components/tooltip_selector'; @@ -14,6 +20,7 @@ import { indexPatternService } from '../../../kibana_services'; import { i18n } from '@kbn/i18n'; import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; import { ValidatedRange } from '../../../components/validated_range'; +import { SORT_ORDER } from '../../../../common/constants'; export class UpdateSourceEditor extends Component { static propTypes = { @@ -21,16 +28,17 @@ export class UpdateSourceEditor extends Component { onChange: PropTypes.func.isRequired, filterByMapBounds: PropTypes.bool.isRequired, tooltipProperties: PropTypes.arrayOf(PropTypes.string).isRequired, + sortField: PropTypes.string, + sortOrder: PropTypes.string.isRequired, useTopHits: PropTypes.bool.isRequired, topHitsSplitField: PropTypes.string, - topHitsTimeField: PropTypes.string, topHitsSize: PropTypes.number.isRequired, }; state = { tooltipFields: null, termFields: null, - dateFields: null, + sortFields: null, }; componentDidMount() { @@ -64,27 +72,11 @@ export class UpdateSourceEditor extends Component { return; } - const dateFields = indexPattern.fields.filter(field => { - return field.type === 'date'; - }); - this.setState({ - dateFields, tooltipFields: getSourceFields(indexPattern.fields), termFields: getTermsFields(indexPattern.fields), + sortFields: indexPattern.fields.filter(field => field.sortable), }); - - if (!this.props.topHitsTimeField) { - // prefer default time field - if (indexPattern.timeFieldName) { - this.onTopHitsTimeFieldChange(indexPattern.timeFieldName); - } else { - // fall back to first date field in index - if (dateFields.length > 0) { - this.onTopHitsTimeFieldChange(dateFields[0].name); - } - } - } } _onTooltipPropertiesChange = propertyNames => { this.props.onChange({ propName: 'tooltipProperties', value: propertyNames }); @@ -102,10 +94,14 @@ export class UpdateSourceEditor extends Component { this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); }; - onTopHitsTimeFieldChange = topHitsTimeField => { - this.props.onChange({ propName: 'topHitsTimeField', value: topHitsTimeField }); + onSortFieldChange = sortField => { + this.props.onChange({ propName: 'sortField', value: sortField }); }; + onSortOrderChange = e => { + this.props.onChange({ propName: 'sortOrder', value: e.target.value }); + } + onTopHitsSizeChange = size => { this.props.onChange({ propName: 'topHitsSize', value: size }); }; @@ -115,31 +111,8 @@ export class UpdateSourceEditor extends Component { return null; } - let timeFieldSelect; let sizeSlider; if (this.props.topHitsSplitField) { - timeFieldSelect = ( - - - - ); - sizeSlider = ( - {timeFieldSelect} - {sizeSlider} ); } render() { - let topHitsCheckbox; - if (this.state.dateFields && this.state.dateFields.length) { - topHitsCheckbox = ( + return ( + + + + + + + + + + + + + + - ); - } - return ( - - + {this.renderTopHitsForm()} - {topHitsCheckbox} - - {this.renderTopHitsForm()} ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js new file mode 100644 index 0000000000000..9a3a74e0ed680 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js @@ -0,0 +1,55 @@ +/* + * 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. + */ + +jest.mock('../../../kibana_services', () => ({})); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { UpdateSourceEditor } from './update_source_editor'; + +const defaultProps = { + indexPatternId: 'indexPattern1', + onChange: () => {}, + filterByMapBounds: true, + tooltipProperties: [], + sortOrder: 'DESC', + useTopHits: false, + topHitsSplitField: 'trackId', + topHitsSize: 1, +}; + +test('should render update source editor', async () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); + +test('should enable sort order select when sort field provided', async () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); + +test('should render top hits form when useTopHits is true', async () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b7a6b24242924..46ca0417d5d72 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5761,8 +5761,6 @@ "xpack.maps.source.esSearch.topHitsSizeLabel": "エンティティごとのドキュメント数", "xpack.maps.source.esSearch.topHitsSplitFieldLabel": "エンティティ", "xpack.maps.source.esSearch.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択", - "xpack.maps.source.esSearch.topHitsTimeFieldLabel": "時間", - "xpack.maps.source.esSearch.topHitsTimeFieldSelectPlaceholder": "タイムフィールドを選択", "xpack.maps.source.esSearch.useTopHitsLabel": "エンティティによる最も最近のドキュメントを表示", "xpack.maps.source.esSearchDescription": "Kibana インデックスパターンの地理空間データ", "xpack.maps.source.esSearchTitle": "ドキュメント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 79cba3ee696be..354db2b217ac6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5764,8 +5764,6 @@ "xpack.maps.source.esSearch.topHitsSizeLabel": "每个实体的文档", "xpack.maps.source.esSearch.topHitsSplitFieldLabel": "实体", "xpack.maps.source.esSearch.topHitsSplitFieldSelectPlaceholder": "选择实体字段", - "xpack.maps.source.esSearch.topHitsTimeFieldLabel": "时间", - "xpack.maps.source.esSearch.topHitsTimeFieldSelectPlaceholder": "选择时间字段", "xpack.maps.source.esSearch.useTopHitsLabel": "按实体显示最近的文档", "xpack.maps.source.esSearchDescription": "Kibana 索引模式的地理空间数据", "xpack.maps.source.esSearchTitle": "文档", diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index d8bdc5e61cb11..e4ef5e5784249 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -42,7 +42,7 @@ export default function ({ getService }) { type: 'index-pattern' } ]); - expect(resp.body.migrationVersion).to.eql({ map: '7.4.0' }); + expect(resp.body.migrationVersion).to.eql({ map: '7.5.0' }); expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); }); }); diff --git a/x-pack/test/functional/apps/maps/joins.js b/x-pack/test/functional/apps/maps/joins.js index 6e7541cc20db8..41c777ad67c77 100644 --- a/x-pack/test/functional/apps/maps/joins.js +++ b/x-pack/test/functional/apps/maps/joins.js @@ -105,7 +105,7 @@ export default function ({ getPageObjects, getService }) { return feature.properties.__kbn__isvisible__; }); - expect(visibilitiesOfFeatures).to.eql([true, true, true, false]); + expect(visibilitiesOfFeatures).to.eql([false, true, true, true]); }); @@ -182,7 +182,7 @@ export default function ({ getPageObjects, getService }) { return feature.properties.__kbn__isvisible__; }); - expect(visibilitiesOfFeatures).to.eql([false, false, true, false]); + expect(visibilitiesOfFeatures).to.eql([false, true, false, false]); });