[Maps] top hits layer wizard (#95678)

* [Maps] top hits source

* add icon

* top hits form

* clean up icon

* create descriptor

* add update editor

* update docs

* tslint and i18n fixes

* update jest test

* add sort inputs to create UI

* review feedback

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2021-04-01 13:21:14 -06:00 committed by GitHub
parent 39ebc8068c
commit a70bd991ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 671 additions and 285 deletions

View file

@ -76,9 +76,8 @@ then accumulates the most relevant documents based on sort order for each entry
To enable top hits:
. Click *Add layer*, then select the *Documents* layer.
. Click *Add layer*, then select the *Top hits per entity* layer.
. Configure *Index pattern* and *Geospatial field*.
. In *Scaling*, select *Show top hits per entity*.
. 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 *Documents per entity* to configure the maximum number of documents accumulated per entity.

View file

@ -23,8 +23,6 @@ Select the appropriate *Scaling* option for your use case.
* *Limit results to 10000.* The layer displays features from the first `index.max_result_window` documents.
Results exceeding `index.max_result_window` are not displayed.
* *Show top hits per entity.* The layer displays the <<maps-top-hits-aggregation, most relevant documents per entity>>.
* *Show clusters when results exceed 10000.* When results exceed `index.max_result_window`, the layer uses {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[GeoTile grid aggregation] to group your documents into clusters and displays metrics for each cluster. When results are less then `index.max_result_window`, the layer displays features from individual documents.
* *Use vector tiles.* Vector tiles partition your map into 6 to 8 tiles.
@ -36,6 +34,9 @@ Tiles exceeding `index.max_result_window` have a visual indicator when there are
*Point to point*:: Aggregated data paths between the source and destination.
The index must contain at least 2 fields mapped as {ref}/geo-point.html[geo_point], source and destination.
*Top hits per entity*:: The layer displays the <<maps-top-hits-aggregation, most relevant documents per entity>>.
The index must contain at least one field mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape].
*Tracks*:: Create lines from points.
The index must contain at least one field mapped as {ref}/geo-point.html[geo_point].

View file

@ -26,6 +26,7 @@ export type MapFilters = {
};
type ESSearchSourceSyncMeta = {
filterByMapBounds: boolean;
sortField: string;
sortOrder: SortDirection;
scalingType: SCALING_TYPES;

View file

@ -95,8 +95,8 @@ export type ESGeoLineSourceDescriptor = AbstractESAggSourceDescriptor & {
export type ESSearchSourceDescriptor = AbstractESSourceDescriptor & {
geoField: string;
filterByMapBounds?: boolean;
tooltipProperties?: string[];
filterByMapBounds: boolean;
tooltipProperties: string[];
sortField: string;
sortOrder: SortDirection;
scalingType: SCALING_TYPES;

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
export const TopHitsLayerIcon: FunctionComponent = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="49" height="25" fill="none" viewBox="0 0 49 25">
<circle cx="24.939" cy="12.409" r="2.182" className="mapLayersWizardIcon__highlight" />
<circle cx="35.849" cy="12.409" r="2.182" className="mapLayersWizardIcon__highlight" />
<circle cx="45.561" cy="12.409" r="3.273" className="mapLayersWizardIcon__highlight" />
<circle cx="5.197" cy="18.954" r="2.182" className="mapLayersWizardIcon__highlight" />
<circle cx="13.485" cy="12.409" r="2.182" className="mapLayersWizardIcon__highlight" />
</svg>
);

View file

@ -13,9 +13,9 @@ export const TracksLayerIcon: FunctionComponent = () => (
className="mapLayersWizardIcon__highlight"
d="M12.733 12.136h32.283v.545H12.935L4.452 19.98l-.356-.413 8.637-7.43z"
/>
<circle cx="24.939" cy="12.409" r="3.273" className="mapLayersWizardIcon__highlight" />
<circle cx="24.939" cy="12.409" r="2.182" className="mapLayersWizardIcon__highlight" />
<circle cx="35.849" cy="12.409" r="2.182" className="mapLayersWizardIcon__highlight" />
<circle cx="45.561" cy="12.409" r="2.182" className="mapLayersWizardIcon__highlight" />
<circle cx="45.561" cy="12.409" r="3.273" className="mapLayersWizardIcon__highlight" />
<circle cx="5.197" cy="18.954" r="2.182" className="mapLayersWizardIcon__highlight" />
<circle cx="13.485" cy="12.409" r="2.182" className="mapLayersWizardIcon__highlight" />
</svg>

View file

@ -7,8 +7,10 @@
import { registerLayerWizard } from './layer_wizard_registry';
import { uploadLayerWizardConfig } from './file_upload_wizard';
// @ts-ignore
import { esDocumentsLayerWizardConfig } from '../sources/es_search_source';
import {
esDocumentsLayerWizardConfig,
esTopHitsLayerWizardConfig,
} from '../sources/es_search_source';
import { clustersLayerWizardConfig, heatmapLayerWizardConfig } from '../sources/es_geo_grid_source';
import { geoLineLayerWizardConfig } from '../sources/es_geo_line_source';
// @ts-ignore
@ -37,13 +39,13 @@ export function registerLayerWizards() {
// Registration order determines display order
registerLayerWizard(uploadLayerWizardConfig);
// @ts-ignore
registerLayerWizard(esDocumentsLayerWizardConfig);
// @ts-ignore
registerLayerWizard(choroplethLayerWizardConfig);
registerLayerWizard(clustersLayerWizardConfig);
// @ts-ignore
registerLayerWizard(heatmapLayerWizardConfig);
registerLayerWizard(esTopHitsLayerWizardConfig);
registerLayerWizard(geoLineLayerWizardConfig);
// @ts-ignore
registerLayerWizard(point2PointLayerWizardConfig);

View file

@ -31,12 +31,6 @@ exports[`scaling form should disable clusters option when clustering is not supp
label="Limit results to 10000."
onChange={[Function]}
/>
<EuiRadio
checked={false}
id="TOP_HITS"
label="Show top hits per entity."
onChange={[Function]}
/>
<EuiToolTip
content="Simulated clustering disabled"
delay="regular"
@ -123,12 +117,6 @@ exports[`scaling form should render 1`] = `
label="Limit results to 10000."
onChange={[Function]}
/>
<EuiRadio
checked={false}
id="TOP_HITS"
label="Show top hits per entity."
onChange={[Function]}
/>
<EuiRadio
checked={false}
disabled={false}
@ -177,110 +165,3 @@ exports[`scaling form should render 1`] = `
</EuiFormRow>
</Fragment>
`;
exports[`scaling form should render top hits form when scaling type is TOP_HITS 1`] = `
<Fragment>
<EuiTitle
size="xs"
>
<h5>
<FormattedMessage
defaultMessage="Scaling"
id="xpack.maps.esSearch.scaleTitle"
values={Object {}}
/>
</h5>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
labelType="label"
>
<div>
<EuiRadio
checked={false}
id="LIMIT"
label="Limit results to 10000."
onChange={[Function]}
/>
<EuiRadio
checked={true}
id="TOP_HITS"
label="Show top hits per entity."
onChange={[Function]}
/>
<EuiRadio
checked={false}
disabled={false}
id="CLUSTERS"
label="Show clusters when results exceed 10000."
onChange={[Function]}
/>
<EuiToolTip
content={
<React.Fragment>
<EuiBetaBadge
label="beta"
/>
<EuiHorizontalRule
margin="xs"
/>
Use vector tiles for faster display of large datasets.
</React.Fragment>
}
delay="regular"
position="left"
>
<EuiRadio
checked={false}
id="MVT"
label="Use vector tiles"
onChange={[Function]}
/>
</EuiToolTip>
</div>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
labelType="label"
>
<EuiSwitch
checked={true}
compressed={true}
label="Dynamically filter for data in the visible map area"
onChange={[Function]}
/>
</EuiFormRow>
<EuiHorizontalRule
margin="xs"
/>
<EuiFormRow
describedByIds={Array []}
display="columnCompressed"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label="Entity"
labelType="label"
>
<SingleFieldSelect
compressed={true}
fields={Array []}
isClearable={false}
onChange={[Function]}
placeholder="Select entity field"
value={null}
/>
</EuiFormRow>
</Fragment>
`;

View file

@ -98,9 +98,6 @@ exports[`should enable sort order select when sort field provided 1`] = `
onChange={[Function]}
scalingType="LIMIT"
supportsClustering={false}
termFields={null}
topHitsSize={1}
topHitsSplitField="trackId"
/>
</EuiPanel>
<EuiSpacer
@ -206,9 +203,6 @@ exports[`should render update source editor 1`] = `
onChange={[Function]}
scalingType="LIMIT"
supportsClustering={false}
termFields={null}
topHitsSize={1}
topHitsSplitField="trackId"
/>
</EuiPanel>
<EuiSpacer

View file

@ -17,7 +17,6 @@ import { DEFAULT_FILTER_BY_MAP_BOUNDS } from './constants';
import { ScalingForm } from './scaling_form';
import {
getGeoFields,
getTermsFields,
getGeoTileAggNotSupportedReason,
supportsGeoTileAgg,
} from '../../../index_pattern_util';
@ -34,8 +33,6 @@ const RESET_INDEX_PATTERN_STATE = {
geoFieldName: undefined,
filterByMapBounds: DEFAULT_FILTER_BY_MAP_BOUNDS,
scalingType: SCALING_TYPES.CLUSTERS, // turn on clusting by default
topHitsSplitField: undefined,
topHitsSize: 1,
};
export class CreateSourceEditor extends Component {
@ -97,14 +94,7 @@ export class CreateSourceEditor extends Component {
};
_previewLayer = () => {
const {
indexPattern,
geoFieldName,
filterByMapBounds,
scalingType,
topHitsSplitField,
topHitsSize,
} = this.state;
const { indexPattern, geoFieldName, filterByMapBounds, scalingType } = this.state;
const sourceConfig =
indexPattern && geoFieldName
@ -113,8 +103,6 @@ export class CreateSourceEditor extends Component {
geoField: geoFieldName,
filterByMapBounds,
scalingType,
topHitsSplitField,
topHitsSize,
}
: null;
this.props.onSourceConfigChange(sourceConfig);
@ -167,9 +155,6 @@ export class CreateSourceEditor extends Component {
)
: null
}
termFields={getTermsFields(this.state.indexPattern.fields)}
topHitsSplitField={this.state.topHitsSplitField}
topHitsSize={this.state.topHitsSize}
/>
</Fragment>
);

View file

@ -10,7 +10,6 @@ import React from 'react';
// @ts-ignore
import { CreateSourceEditor } from './create_source_editor';
import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry';
// @ts-ignore
import { ESSearchSource, sourceTitle } from './es_search_source';
import { BlendedVectorLayer } from '../../layers/blended_vector_layer/blended_vector_layer';
import { VectorLayer } from '../../layers/vector_layer';

View file

@ -60,6 +60,7 @@ import { ITooltipProperty } from '../../tooltips/tooltip_property';
import { DataRequest } from '../../util/data_request';
import { SortDirection, SortDirectionNumeric } from '../../../../../../../src/plugins/data/common';
import { isValidStringConfig } from '../../util/valid_string_config';
import { TopHitsUpdateSourceEditor } from './top_hits';
export const sourceTitle = i18n.translate('xpack.maps.source.esSearchTitle', {
defaultMessage: 'Documents',
@ -166,6 +167,22 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye
}
renderSourceSettingsEditor(sourceEditorArgs: SourceEditorArgs): ReactElement<any> | null {
if (this._isTopHits()) {
return (
<TopHitsUpdateSourceEditor
source={this}
indexPatternId={this.getIndexPatternId()}
onChange={sourceEditorArgs.onChange}
tooltipFields={this._tooltipFields}
sortField={this._descriptor.sortField}
sortOrder={this._descriptor.sortOrder}
filterByMapBounds={this.isFilterByMapBounds()}
topHitsSplitField={this._descriptor.topHitsSplitField}
topHitsSize={this._descriptor.topHitsSize}
/>
);
}
const getGeoField = () => {
return this._getGeoField();
};
@ -180,8 +197,6 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye
sortOrder={this._descriptor.sortOrder}
scalingType={this._descriptor.scalingType}
filterByMapBounds={this.isFilterByMapBounds()}
topHitsSplitField={this._descriptor.topHitsSplitField}
topHitsSize={this._descriptor.topHitsSize}
/>
);
}
@ -658,6 +673,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye
getSyncMeta(): VectorSourceSyncMeta | null {
return {
filterByMapBounds: this._descriptor.filterByMapBounds,
sortField: this._descriptor.sortField,
sortOrder: this._descriptor.sortOrder,
scalingType: this._descriptor.scalingType,

View file

@ -11,3 +11,4 @@ export {
createDefaultLayerDescriptor,
esDocumentsLayerWizardConfig,
} from './es_documents_layer_wizard';
export { esTopHitsLayerWizardConfig } from './top_hits';

View file

@ -26,8 +26,6 @@ const defaultProps = {
scalingType: SCALING_TYPES.LIMIT,
supportsClustering: true,
termFields: [],
topHitsSplitField: null,
topHitsSize: 1,
};
describe('scaling form', () => {
@ -48,12 +46,4 @@ describe('scaling form', () => {
expect(component).toMatchSnapshot();
});
test('should render top hits form when scaling type is TOP_HITS', async () => {
const component = shallow(
<ScalingForm {...defaultProps} scalingType={SCALING_TYPES.TOP_HITS} />
);
expect(component).toMatchSnapshot();
});
});

View file

@ -19,19 +19,9 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { SingleFieldSelect } from '../../../components/single_field_select';
import { getIndexPatternService } from '../../../kibana_services';
// @ts-ignore
import { ValidatedRange } from '../../../components/validated_range';
import {
DEFAULT_MAX_INNER_RESULT_WINDOW,
DEFAULT_MAX_RESULT_WINDOW,
LAYER_TYPE,
SCALING_TYPES,
} from '../../../../common/constants';
// @ts-ignore
import { DEFAULT_MAX_RESULT_WINDOW, LAYER_TYPE, SCALING_TYPES } from '../../../../common/constants';
import { loadIndexSettings } from './load_index_settings';
import { IFieldType } from '../../../../../../../src/plugins/data/public';
import { OnSourceChangeArgs } from '../../../connected_components/layer_panel/view';
interface Props {
@ -41,19 +31,14 @@ interface Props {
scalingType: SCALING_TYPES;
supportsClustering: boolean;
clusteringDisabledReason?: string | null;
termFields: IFieldType[];
topHitsSplitField: string | null;
topHitsSize: number;
}
interface State {
maxInnerResultWindow: number;
maxResultWindow: number;
}
export class ScalingForm extends Component<Props, State> {
state = {
maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW,
maxResultWindow: DEFAULT_MAX_RESULT_WINDOW,
};
_isMounted = false;
@ -70,11 +55,9 @@ export class ScalingForm extends Component<Props, State> {
async loadIndexSettings() {
try {
const indexPattern = await getIndexPatternService().get(this.props.indexPatternId);
const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(
indexPattern!.title
);
const { maxResultWindow } = await loadIndexSettings(indexPattern!.title);
if (this._isMounted) {
this.setState({ maxInnerResultWindow, maxResultWindow });
this.setState({ maxResultWindow });
}
} catch (err) {
return;
@ -98,71 +81,6 @@ export class ScalingForm extends Component<Props, State> {
this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked });
};
_onTopHitsSplitFieldChange = (topHitsSplitField?: string) => {
if (!topHitsSplitField) {
return;
}
this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField });
};
_onTopHitsSizeChange = (size: number) => {
this.props.onChange({ propName: 'topHitsSize', value: size });
};
_renderTopHitsForm() {
let sizeSlider;
if (this.props.topHitsSplitField) {
sizeSlider = (
<EuiFormRow
label={i18n.translate('xpack.maps.source.esSearch.topHitsSizeLabel', {
defaultMessage: 'Documents per entity',
})}
display="columnCompressed"
>
<ValidatedRange
min={1}
max={this.state.maxInnerResultWindow}
step={1}
value={this.props.topHitsSize}
onChange={this._onTopHitsSizeChange}
showLabels
showInput
showRange
data-test-subj="layerPanelTopHitsSize"
compressed
/>
</EuiFormRow>
);
}
return (
<Fragment>
<EuiFormRow
label={i18n.translate('xpack.maps.source.esSearch.topHitsSplitFieldLabel', {
defaultMessage: 'Entity',
})}
display="columnCompressed"
>
<SingleFieldSelect
placeholder={i18n.translate(
'xpack.maps.source.esSearch.topHitsSplitFieldSelectPlaceholder',
{
defaultMessage: 'Select entity field',
}
)}
value={this.props.topHitsSplitField}
onChange={this._onTopHitsSplitFieldChange}
fields={this.props.termFields}
isClearable={false}
compressed
/>
</EuiFormRow>
{sizeSlider}
</Fragment>
);
}
_renderClusteringRadio() {
const clusteringRadio = (
<EuiRadio
@ -218,10 +136,7 @@ export class ScalingForm extends Component<Props, State> {
render() {
let filterByBoundsSwitch;
if (
this.props.scalingType === SCALING_TYPES.TOP_HITS ||
this.props.scalingType === SCALING_TYPES.LIMIT
) {
if (this.props.scalingType === SCALING_TYPES.LIMIT) {
filterByBoundsSwitch = (
<EuiFormRow>
<EuiSwitch
@ -236,16 +151,6 @@ export class ScalingForm extends Component<Props, State> {
);
}
let topHitsOptionsForm = null;
if (this.props.scalingType === SCALING_TYPES.TOP_HITS) {
topHitsOptionsForm = (
<Fragment>
<EuiHorizontalRule margin="xs" />
{this._renderTopHitsForm()}
</Fragment>
);
}
return (
<Fragment>
<EuiTitle size="xs">
@ -267,21 +172,12 @@ export class ScalingForm extends Component<Props, State> {
checked={this.props.scalingType === SCALING_TYPES.LIMIT}
onChange={() => this._onScalingTypeChange(SCALING_TYPES.LIMIT)}
/>
<EuiRadio
id={SCALING_TYPES.TOP_HITS}
label={i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', {
defaultMessage: 'Show top hits per entity.',
})}
checked={this.props.scalingType === SCALING_TYPES.TOP_HITS}
onChange={() => this._onScalingTypeChange(SCALING_TYPES.TOP_HITS)}
/>
{this._renderClusteringRadio()}
{this._renderMVTRadio()}
</div>
</EuiFormRow>
{filterByBoundsSwitch}
{topHitsOptionsForm}
</Fragment>
);
}

View file

@ -0,0 +1,162 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Component } from 'react';
import { EuiPanel } from '@elastic/eui';
import { SCALING_TYPES } from '../../../../../common/constants';
import { GeoFieldSelect } from '../../../../components/geo_field_select';
import { GeoIndexPatternSelect } from '../../../../components/geo_index_pattern_select';
import { getGeoFields, getTermsFields, getSortFields } from '../../../../index_pattern_util';
import { ESSearchSourceDescriptor } from '../../../../../common/descriptor_types';
import {
IndexPattern,
IFieldType,
SortDirection,
} from '../../../../../../../../src/plugins/data/common';
import { TopHitsForm } from './top_hits_form';
import { OnSourceChangeArgs } from '../../../../connected_components/layer_panel/view';
interface Props {
onSourceConfigChange: (sourceConfig: Partial<ESSearchSourceDescriptor> | null) => void;
}
interface State {
indexPattern: IndexPattern | null;
geoFields: IFieldType[];
geoFieldName: string | null;
sortField: string | null;
sortFields: IFieldType[];
sortOrder: SortDirection;
termFields: IFieldType[];
topHitsSplitField: string | null;
topHitsSize: number;
}
export class CreateSourceEditor extends Component<Props, State> {
state: State = {
indexPattern: null,
geoFields: [],
geoFieldName: null,
sortField: null,
sortFields: [],
sortOrder: SortDirection.desc,
termFields: [],
topHitsSplitField: null,
topHitsSize: 1,
};
_onIndexPatternSelect = (indexPattern: IndexPattern) => {
const geoFields = getGeoFields(indexPattern.fields);
this.setState(
{
indexPattern,
geoFields,
geoFieldName: geoFields.length ? geoFields[0].name : null,
sortField: indexPattern.timeFieldName ? indexPattern.timeFieldName : null,
sortFields: getSortFields(indexPattern.fields),
termFields: getTermsFields(indexPattern.fields),
topHitsSplitField: null,
},
this._previewLayer
);
};
_onGeoFieldSelect = (geoFieldName?: string) => {
this.setState({ geoFieldName: geoFieldName ? geoFieldName : null }, this._previewLayer);
};
_onTopHitsPropChange = ({ propName, value }: OnSourceChangeArgs) => {
this.setState(
// @ts-expect-error
{ [propName]: value },
this._previewLayer
);
};
_previewLayer = () => {
const {
indexPattern,
geoFieldName,
sortField,
sortOrder,
topHitsSplitField,
topHitsSize,
} = this.state;
const tooltipProperties: string[] = [];
if (topHitsSplitField) {
tooltipProperties.push(topHitsSplitField);
}
if (indexPattern && indexPattern.timeFieldName) {
tooltipProperties.push(indexPattern.timeFieldName);
}
const sourceConfig =
indexPattern && geoFieldName && sortField && topHitsSplitField
? {
indexPatternId: indexPattern.id,
geoField: geoFieldName,
scalingType: SCALING_TYPES.TOP_HITS,
sortField,
sortOrder,
tooltipProperties,
topHitsSplitField,
topHitsSize,
}
: null;
this.props.onSourceConfigChange(sourceConfig);
};
_renderGeoSelect() {
return this.state.indexPattern ? (
<GeoFieldSelect
value={this.state.geoFieldName ? this.state.geoFieldName : ''}
onChange={this._onGeoFieldSelect}
geoFields={this.state.geoFields}
/>
) : null;
}
_renderTopHitsPanel() {
if (!this.state.indexPattern || !this.state.indexPattern.id || !this.state.geoFieldName) {
return null;
}
return (
<TopHitsForm
indexPatternId={this.state.indexPattern.id}
isColumnCompressed={false}
onChange={this._onTopHitsPropChange}
sortField={this.state.sortField ? this.state.sortField : ''}
sortFields={this.state.sortFields}
sortOrder={this.state.sortOrder}
termFields={this.state.termFields}
topHitsSplitField={this.state.topHitsSplitField}
topHitsSize={this.state.topHitsSize}
/>
);
}
render() {
return (
<EuiPanel>
<GeoIndexPatternSelect
value={
this.state.indexPattern && this.state.indexPattern.id ? this.state.indexPattern.id : ''
}
onChange={this._onIndexPatternSelect}
/>
{this._renderGeoSelect()}
{this._renderTopHitsPanel()}
</EuiPanel>
);
}
}

View file

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

View file

@ -0,0 +1,190 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ChangeEvent, Component, Fragment } from 'react';
import { EuiFormRow, EuiSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SingleFieldSelect } from '../../../../components/single_field_select';
import { getIndexPatternService } from '../../../../kibana_services';
// @ts-expect-error
import { ValidatedRange } from '../../../../components/validated_range';
import { DEFAULT_MAX_INNER_RESULT_WINDOW } from '../../../../../common/constants';
import { loadIndexSettings } from '../load_index_settings';
import { OnSourceChangeArgs } from '../../../../connected_components/layer_panel/view';
import { IFieldType, SortDirection } from '../../../../../../../../src/plugins/data/public';
interface Props {
indexPatternId: string;
isColumnCompressed?: boolean;
onChange: (args: OnSourceChangeArgs) => void;
sortField: string;
sortFields: IFieldType[];
sortOrder: SortDirection;
termFields: IFieldType[];
topHitsSplitField: string | null;
topHitsSize: number;
}
interface State {
maxInnerResultWindow: number;
}
export class TopHitsForm extends Component<Props, State> {
state = {
maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW,
};
_isMounted = false;
componentDidMount() {
this._isMounted = true;
this.loadIndexSettings();
}
componentWillUnmount() {
this._isMounted = false;
}
_onTopHitsSplitFieldChange = (topHitsSplitField?: string) => {
if (!topHitsSplitField) {
return;
}
this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField });
};
_onTopHitsSizeChange = (size: number) => {
this.props.onChange({ propName: 'topHitsSize', value: size });
};
_onSortFieldChange = (sortField?: string) => {
this.props.onChange({ propName: 'sortField', value: sortField });
};
_onSortOrderChange = (event: ChangeEvent<HTMLSelectElement>) => {
this.props.onChange({ propName: 'sortOrder', value: event.target.value });
};
async loadIndexSettings() {
try {
const indexPattern = await getIndexPatternService().get(this.props.indexPatternId);
const { maxInnerResultWindow } = await loadIndexSettings(indexPattern!.title);
if (this._isMounted) {
this.setState({ maxInnerResultWindow });
}
} catch (err) {
return;
}
}
render() {
let sizeSlider;
let sortField;
let sortOrder;
if (this.props.topHitsSplitField) {
sizeSlider = (
<EuiFormRow
label={i18n.translate('xpack.maps.source.esSearch.topHitsSizeLabel', {
defaultMessage: 'Documents per entity',
})}
display={this.props.isColumnCompressed ? 'columnCompressed' : 'row'}
>
<ValidatedRange
min={1}
max={this.state.maxInnerResultWindow}
step={1}
value={this.props.topHitsSize}
onChange={this._onTopHitsSizeChange}
showLabels
showInput
showRange
data-test-subj="layerPanelTopHitsSize"
compressed
/>
</EuiFormRow>
);
sortField = (
<EuiFormRow
label={i18n.translate('xpack.maps.source.esTopHitsSearch.sortFieldLabel', {
defaultMessage: 'Sort field',
})}
display={this.props.isColumnCompressed ? 'columnCompressed' : 'row'}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.maps.source.esSearch.sortFieldSelectPlaceholder', {
defaultMessage: 'Select sort field',
})}
value={this.props.sortField}
onChange={this._onSortFieldChange}
fields={this.props.sortFields}
compressed
/>
</EuiFormRow>
);
sortOrder = (
<EuiFormRow
label={i18n.translate('xpack.maps.source.esTopHitsSearch.sortOrderLabel', {
defaultMessage: 'Sort order',
})}
display={this.props.isColumnCompressed ? 'columnCompressed' : 'row'}
>
<EuiSelect
disabled={!this.props.sortField}
options={[
{
text: i18n.translate('xpack.maps.source.esSearch.ascendingLabel', {
defaultMessage: 'ascending',
}),
value: SortDirection.asc,
},
{
text: i18n.translate('xpack.maps.source.esSearch.descendingLabel', {
defaultMessage: 'descending',
}),
value: SortDirection.desc,
},
]}
value={this.props.sortOrder}
onChange={this._onSortOrderChange}
compressed
/>
</EuiFormRow>
);
}
return (
<Fragment>
<EuiFormRow
label={i18n.translate('xpack.maps.source.esSearch.topHitsSplitFieldLabel', {
defaultMessage: 'Entity',
})}
display={this.props.isColumnCompressed ? 'columnCompressed' : 'row'}
>
<SingleFieldSelect
placeholder={i18n.translate(
'xpack.maps.source.esSearch.topHitsSplitFieldSelectPlaceholder',
{
defaultMessage: 'Select entity field',
}
)}
value={this.props.topHitsSplitField}
onChange={this._onTopHitsSplitFieldChange}
fields={this.props.termFields}
isClearable={false}
compressed
/>
</EuiFormRow>
{sizeSlider}
{sortField}
{sortOrder}
</Fragment>
);
}
}

View file

@ -0,0 +1,168 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Component, Fragment } from 'react';
import { EuiFormRow, EuiTitle, EuiPanel, EuiSpacer, EuiSwitch, EuiSwitchEvent } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { FIELD_ORIGIN } from '../../../../../common/constants';
import { TooltipSelector } from '../../../../components/tooltip_selector';
import { getIndexPatternService } from '../../../../kibana_services';
import { getTermsFields, getSortFields, getSourceFields } from '../../../../index_pattern_util';
import { SortDirection, IFieldType } from '../../../../../../../../src/plugins/data/public';
import { ESDocField } from '../../../fields/es_doc_field';
import { OnSourceChangeArgs } from '../../../../connected_components/layer_panel/view';
import { TopHitsForm } from './top_hits_form';
import { ESSearchSource } from '../es_search_source';
import { IField } from '../../../fields/field';
interface Props {
filterByMapBounds: boolean;
indexPatternId: string;
onChange: (args: OnSourceChangeArgs) => void;
tooltipFields: IField[];
topHitsSplitField: string;
topHitsSize: number;
sortField: string;
sortOrder: SortDirection;
source: ESSearchSource;
}
interface State {
loadError?: string;
sourceFields: IField[];
termFields: IFieldType[];
sortFields: IFieldType[];
}
export class TopHitsUpdateSourceEditor extends Component<Props, State> {
private _isMounted = false;
state: State = {
sourceFields: [],
termFields: [],
sortFields: [],
};
componentDidMount() {
this._isMounted = true;
this.loadFields();
}
componentWillUnmount() {
this._isMounted = false;
}
async loadFields() {
let indexPattern;
try {
indexPattern = await getIndexPatternService().get(this.props.indexPatternId);
} catch (err) {
if (this._isMounted) {
this.setState({
loadError: i18n.translate('xpack.maps.source.esSearch.loadErrorMessage', {
defaultMessage: `Unable to find Index pattern {id}`,
values: {
id: this.props.indexPatternId,
},
}),
});
}
return;
}
if (!this._isMounted) {
return;
}
const rawTooltipFields = getSourceFields(indexPattern.fields);
const sourceFields = rawTooltipFields.map((field) => {
return new ESDocField({
fieldName: field.name,
source: this.props.source,
origin: FIELD_ORIGIN.SOURCE,
});
});
this.setState({
sourceFields,
termFields: getTermsFields(indexPattern.fields),
sortFields: getSortFields(indexPattern.fields),
});
}
_onTooltipPropertiesChange = (propertyNames: string[]) => {
this.props.onChange({ propName: 'tooltipProperties', value: propertyNames });
};
_onFilterByMapBoundsChange = (event: EuiSwitchEvent) => {
this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked });
};
render() {
return (
<Fragment>
<EuiPanel>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.maps.esSearch.tooltipsTitle"
defaultMessage="Tooltip fields"
/>
</h5>
</EuiTitle>
<EuiSpacer size="m" />
<TooltipSelector
tooltipFields={this.props.tooltipFields}
onChange={this._onTooltipPropertiesChange}
fields={this.state.sourceFields}
/>
</EuiPanel>
<EuiSpacer size="s" />
<EuiPanel>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.maps.source.topHitsPanelLabel"
defaultMessage="Top hits"
/>
</h5>
</EuiTitle>
<EuiSpacer size="m" />
<TopHitsForm
indexPatternId={this.props.indexPatternId}
isColumnCompressed={true}
onChange={this.props.onChange}
sortField={this.props.sortField}
sortFields={this.state.sortFields}
sortOrder={this.props.sortOrder}
termFields={this.state.termFields}
topHitsSplitField={this.props.topHitsSplitField}
topHitsSize={this.props.topHitsSize}
/>
<EuiFormRow>
<EuiSwitch
label={i18n.translate('xpack.maps.source.esSearch.extentFilterLabel', {
defaultMessage: 'Dynamically filter for data in the visible map area',
})}
checked={this.props.filterByMapBounds}
onChange={this._onFilterByMapBoundsChange}
compressed
/>
</EuiFormRow>
</EuiPanel>
<EuiSpacer size="s" />
</Fragment>
);
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { CreateSourceEditor } from './create_source_editor';
import { LayerWizard, RenderWizardArguments } from '../../../layers/layer_wizard_registry';
import { VectorLayer } from '../../../layers/vector_layer';
import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants';
import { TopHitsLayerIcon } from '../../../layers/icons/top_hits_layer_icon';
import { ESSearchSourceDescriptor } from '../../../../../common/descriptor_types';
import { ESSearchSource } from '../es_search_source';
export const esTopHitsLayerWizardConfig: LayerWizard = {
categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH],
description: i18n.translate('xpack.maps.source.topHitsDescription', {
defaultMessage:
'Display the most relevant documents per entity, e.g. the most recent GPS hits per vehicle.',
}),
icon: TopHitsLayerIcon,
renderWizard: ({ previewLayers, mapColors }: RenderWizardArguments) => {
const onSourceConfigChange = (sourceConfig: Partial<ESSearchSourceDescriptor> | null) => {
if (!sourceConfig) {
previewLayers([]);
return;
}
const sourceDescriptor = ESSearchSource.createDescriptor(sourceConfig);
const layerDescriptor = VectorLayer.createDescriptor({ sourceDescriptor }, mapColors);
previewLayers([layerDescriptor]);
};
return <CreateSourceEditor onSourceConfigChange={onSourceConfigChange} />;
},
title: i18n.translate('xpack.maps.source.topHitsTitle', {
defaultMessage: 'Top hits per entity',
}),
};

View file

@ -8,6 +8,7 @@
import React, { Fragment, Component } from 'react';
import PropTypes from 'prop-types';
import { EuiFormRow, EuiSelect, EuiTitle, EuiPanel, EuiSpacer } from '@elastic/eui';
import { FIELD_ORIGIN } from '../../../../common/constants';
import { SingleFieldSelect } from '../../../components/single_field_select';
import { TooltipSelector } from '../../../components/tooltip_selector';
@ -15,7 +16,6 @@ import { getIndexPatternService } from '../../../kibana_services';
import { i18n } from '@kbn/i18n';
import {
getGeoTileAggNotSupportedReason,
getTermsFields,
getSourceFields,
supportsGeoTileAgg,
} from '../../../index_pattern_util';
@ -33,14 +33,11 @@ export class UpdateSourceEditor extends Component {
sortField: PropTypes.string,
sortOrder: PropTypes.string.isRequired,
scalingType: PropTypes.string.isRequired,
topHitsSplitField: PropTypes.string,
topHitsSize: PropTypes.number.isRequired,
source: PropTypes.object,
};
state = {
sourceFields: null,
termFields: null,
sortFields: null,
supportsClustering: false,
mvtDisabledReason: null,
@ -94,6 +91,7 @@ export class UpdateSourceEditor extends Component {
return new ESDocField({
fieldName: field.name,
source: this.props.source,
origin: FIELD_ORIGIN.SOURCE,
});
});
@ -102,7 +100,6 @@ export class UpdateSourceEditor extends Component {
clusteringDisabledReason: getGeoTileAggNotSupportedReason(geoField),
mvtDisabledReason: null,
sourceFields: sourceFields,
termFields: getTermsFields(indexPattern.fields), //todo change term fields to use fields
sortFields: indexPattern.fields.filter(
(field) => field.sortable && !indexPatterns.isNestedField(field)
), //todo change sort fields to use fields
@ -212,9 +209,6 @@ export class UpdateSourceEditor extends Component {
scalingType={this.props.scalingType}
supportsClustering={this.state.supportsClustering}
clusteringDisabledReason={this.state.clusteringDisabledReason}
termFields={this.state.termFields}
topHitsSplitField={this.props.topHitsSplitField}
topHitsSize={this.props.topHitsSize}
/>
</EuiPanel>
);

View file

@ -26,8 +26,6 @@ const defaultProps = {
tooltipFields: [],
sortOrder: 'DESC',
scalingType: SCALING_TYPES.LIMIT,
topHitsSplitField: 'trackId',
topHitsSize: 1,
};
test('should render update source editor', async () => {

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow } from '@elastic/eui';
import { SingleFieldSelect } from './single_field_select';
import { IFieldType } from '../../../../../src/plugins/data/common';
interface Props {
value: string;
geoFields: IFieldType[];
onChange: (geoFieldName?: string) => void;
}
export function GeoFieldSelect(props: Props) {
return (
<EuiFormRow
label={i18n.translate('xpack.maps.source.geofieldLabel', {
defaultMessage: 'Geospatial field',
})}
>
<SingleFieldSelect
placeholder={i18n.translate('xpack.maps.source.selectLabel', {
defaultMessage: 'Select geo field',
})}
value={props.value}
onChange={props.onChange}
fields={props.geoFields}
/>
</EuiFormRow>
);
}

View file

@ -56,6 +56,12 @@ export function getTermsFields(fields: IFieldType[]): IFieldType[] {
});
}
export function getSortFields(fields: IFieldType[]): IFieldType[] {
return fields.filter((field) => {
return field.sortable && !indexPatterns.isNestedField(field);
});
}
export function getAggregatableGeoFieldTypes(): string[] {
const aggregatableFieldTypes = [ES_GEO_FIELD_TYPE.GEO_POINT];
if (getIsGoldPlus()) {

View file

@ -12762,7 +12762,6 @@
"xpack.maps.source.esSearch.topHitsSplitFieldLabel": "エンティティ",
"xpack.maps.source.esSearch.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択",
"xpack.maps.source.esSearch.useMVTVectorTiles": "ベクトルタイルを使用",
"xpack.maps.source.esSearch.useTopHitsLabel": "エンティティごとにトップヒットを表示。",
"xpack.maps.source.esSearchDescription": "Elasticsearch の点、線、多角形",
"xpack.maps.source.esSearchTitle": "ドキュメント",
"xpack.maps.source.esSource.noGeoFieldErrorMessage": "インデックスパターン {indexPatternTitle} には現在ジオフィールド {geoField} が含まれていません",

View file

@ -12930,7 +12930,6 @@
"xpack.maps.source.esSearch.topHitsSplitFieldLabel": "实体",
"xpack.maps.source.esSearch.topHitsSplitFieldSelectPlaceholder": "选择实体字段",
"xpack.maps.source.esSearch.useMVTVectorTiles": "使用矢量磁贴",
"xpack.maps.source.esSearch.useTopHitsLabel": "显示每个实体最高命中结果。",
"xpack.maps.source.esSearchDescription": "Elasticsearch 的点、线和多边形",
"xpack.maps.source.esSearchTitle": "文档",
"xpack.maps.source.esSource.noGeoFieldErrorMessage": "索引模式“{indexPatternTitle}”不再包含地理字段 {geoField}",