diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index d357f11f5e3e1e..ad99780a7d32fb 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -99,6 +99,9 @@ export enum ES_GEO_FIELD_TYPE { GEO_SHAPE = 'geo_shape', } +// Using strings instead of ES_GEO_FIELD_TYPE enum to avoid typeing errors where IFieldType.type is compared to value +export const ES_GEO_FIELD_TYPES = ['geo_point', 'geo_shape']; + export enum ES_SPATIAL_RELATIONS { INTERSECTS = 'INTERSECTS', DISJOINT = 'DISJOINT', diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js index 91dcb057dd8378..24edf0251c1535 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js @@ -8,15 +8,25 @@ import _ from 'lodash'; import React, { Fragment, Component } from 'react'; import PropTypes from 'prop-types'; +import { ES_GEO_FIELD_TYPES } from '../../../../common/constants'; import { SingleFieldSelect } from '../../../components/single_field_select'; import { getIndexPatternService, getIndexPatternSelectComponent } from '../../../kibana_services'; import { NoIndexPatternCallout } from '../../../components/no_index_pattern_callout'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSpacer } from '@elastic/eui'; -import { getAggregatableGeoFieldTypes, getFieldsWithGeoTileAgg } from '../../../index_pattern_util'; +import { + getFieldsWithGeoTileAgg, + getGeoFields, + getGeoTileAggNotSupportedReason, + supportsGeoTileAgg, +} from '../../../index_pattern_util'; import { RenderAsSelect } from './render_as_select'; +function doesNotSupportGeoTileAgg(field) { + return !supportsGeoTileAgg(field); +} + export class CreateSourceEditor extends Component { static propTypes = { onSourceConfigChange: PropTypes.func.isRequired, @@ -87,9 +97,9 @@ export class CreateSourceEditor extends Component { }); //make default selection - const geoFields = getFieldsWithGeoTileAgg(indexPattern.fields); - if (geoFields[0]) { - this._onGeoFieldSelect(geoFields[0].name); + const geoFieldsWithGeoTileAgg = getFieldsWithGeoTileAgg(indexPattern.fields); + if (geoFieldsWithGeoTileAgg[0]) { + this._onGeoFieldSelect(geoFieldsWithGeoTileAgg[0].name); } }, 300); @@ -141,10 +151,10 @@ export class CreateSourceEditor extends Component { value={this.state.geoField} onChange={this._onGeoFieldSelect} fields={ - this.state.indexPattern - ? getFieldsWithGeoTileAgg(this.state.indexPattern.fields) - : undefined + this.state.indexPattern ? getGeoFields(this.state.indexPattern.fields) : undefined } + isFieldDisabled={doesNotSupportGeoTileAgg} + getFieldDisabledReason={getGeoTileAggNotSupportedReason} /> ); @@ -176,7 +186,7 @@ export class CreateSourceEditor extends Component { placeholder={i18n.translate('xpack.maps.source.esGeoGrid.indexPatternPlaceholder', { defaultMessage: 'Select index pattern', })} - fieldTypes={getAggregatableGeoFieldTypes()} + fieldTypes={ES_GEO_FIELD_TYPES} onNoIndexPatterns={this._onNoIndexPatterns} /> diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap b/x-pack/plugins/maps/public/classes/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap index 967225d6f0fdca..2b04da92517561 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should not render clusters option when clustering is not supported 1`] = ` +exports[`should disable clusters option when clustering is not supported 1`] = ` - +
+ + + + + +
- +
+ + + +
- +
+ + + +
{ - return ( - !indexPatterns.isNestedField(field) && - [ES_GEO_FIELD_TYPE.GEO_POINT, ES_GEO_FIELD_TYPE.GEO_SHAPE].includes(field.type) - ); - }); -} +import { + getGeoFields, + getTermsFields, + getGeoTileAggNotSupportedReason, + supportsGeoTileAgg, +} from '../../../index_pattern_util'; function doesGeoFieldSupportGeoTileAgg(indexPattern, geoFieldName) { return indexPattern ? supportsGeoTileAgg(indexPattern.fields.getByName(geoFieldName)) : false; @@ -217,6 +212,13 @@ export class CreateSourceEditor extends Component { this.state.indexPattern, this.state.geoFieldName )} + clusteringDisabledReason={ + this.state.indexPattern + ? getGeoTileAggNotSupportedReason( + this.state.indexPattern.fields.getByName(this.state.geoFieldName) + ) + : null + } termFields={getTermsFields(this.state.indexPattern.fields)} topHitsSplitField={this.state.topHitsSplitField} topHitsSize={this.state.topHitsSize} @@ -260,7 +262,7 @@ export class CreateSourceEditor extends Component { defaultMessage: 'Select index pattern', } )} - fieldTypes={[ES_GEO_FIELD_TYPE.GEO_POINT, ES_GEO_FIELD_TYPE.GEO_SHAPE]} + fieldTypes={ES_GEO_FIELD_TYPES} onNoIndexPatterns={this._onNoIndexPatterns} /> diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.test.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.test.tsx index 03f29685891ecf..3ec746223c7cf3 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.test.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.test.tsx @@ -34,8 +34,14 @@ test('should render', async () => { expect(component).toMatchSnapshot(); }); -test('should not render clusters option when clustering is not supported', async () => { - const component = shallow(); +test('should disable clusters option when clustering is not supported', async () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.tsx index 829c9a1ce439da..a998fe3569835c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/scaling_form.tsx @@ -12,11 +12,11 @@ import { EuiTitle, EuiSpacer, EuiHorizontalRule, - EuiRadioGroup, + EuiRadio, + EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -// @ts-ignore import { SingleFieldSelect } from '../../../components/single_field_select'; import { getIndexPatternService } from '../../../kibana_services'; // @ts-ignore @@ -38,6 +38,7 @@ interface Props { onChange: (args: OnSourceChangeArgs) => void; scalingType: SCALING_TYPES; supportsClustering: boolean; + clusteringDisabledReason?: string | null; termFields: IFieldType[]; topHitsSplitField?: string; topHitsSize: number; @@ -88,7 +89,7 @@ export class ScalingForm extends Component { this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked }); }; - _onTopHitsSplitFieldChange = (topHitsSplitField: string) => { + _onTopHitsSplitFieldChange = (topHitsSplitField?: string) => { this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); }; @@ -149,32 +150,30 @@ export class ScalingForm extends Component { ); } - render() { - const scalingOptions = [ - { - id: SCALING_TYPES.LIMIT, - label: i18n.translate('xpack.maps.source.esSearch.limitScalingLabel', { - defaultMessage: 'Limit results to {maxResultWindow}.', - values: { maxResultWindow: this.state.maxResultWindow }, - }), - }, - { - id: SCALING_TYPES.TOP_HITS, - label: i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', { - defaultMessage: 'Show top hits per entity.', - }), - }, - ]; - if (this.props.supportsClustering) { - scalingOptions.push({ - id: SCALING_TYPES.CLUSTERS, - label: i18n.translate('xpack.maps.source.esSearch.clusterScalingLabel', { + _renderClusteringRadio() { + const clusteringRadio = ( + this._onScalingTypeChange(SCALING_TYPES.CLUSTERS)} + disabled={!this.props.supportsClustering} + /> + ); + return this.props.clusteringDisabledReason ? ( + + {clusteringRadio} + + ) : ( + clusteringRadio + ); + } + + render() { let filterByBoundsSwitch; if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { filterByBoundsSwitch = ( @@ -212,11 +211,26 @@ export class ScalingForm extends Component { - +
+ this._onScalingTypeChange(SCALING_TYPES.LIMIT)} + /> + this._onScalingTypeChange(SCALING_TYPES.TOP_HITS)} + /> + {this._renderClusteringRadio()} +
{filterByBoundsSwitch} diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/update_source_editor.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/update_source_editor.js index 95e48c9629f57b..0701dbbaecdd56 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/update_source_editor.js +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/update_source_editor.js @@ -12,7 +12,12 @@ import { TooltipSelector } from '../../../components/tooltip_selector'; import { getIndexPatternService } from '../../../kibana_services'; import { i18n } from '@kbn/i18n'; -import { getTermsFields, getSourceFields, supportsGeoTileAgg } from '../../../index_pattern_util'; +import { + getGeoTileAggNotSupportedReason, + getTermsFields, + getSourceFields, + supportsGeoTileAgg, +} from '../../../index_pattern_util'; import { SORT_ORDER } from '../../../../common/constants'; import { ESDocField } from '../../fields/es_doc_field'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -91,6 +96,7 @@ export class UpdateSourceEditor extends Component { this.setState({ supportsClustering: supportsGeoTileAgg(geoField), + clusteringDisabledReason: getGeoTileAggNotSupportedReason(geoField), sourceFields: sourceFields, termFields: getTermsFields(indexPattern.fields), //todo change term fields to use fields sortFields: indexPattern.fields.filter( @@ -201,6 +207,7 @@ export class UpdateSourceEditor extends Component { onChange={this.props.onChange} scalingType={this.props.scalingType} supportsClustering={this.state.supportsClustering} + clusteringDisabledReason={this.state.clusteringDisabledReason} termFields={this.state.termFields} topHitsSplitField={this.props.topHitsSplitField} topHitsSize={this.props.topHitsSize} diff --git a/x-pack/plugins/maps/public/components/single_field_select.js b/x-pack/plugins/maps/public/components/single_field_select.js deleted file mode 100644 index a4db361da9c623..00000000000000 --- a/x-pack/plugins/maps/public/components/single_field_select.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 PropTypes from 'prop-types'; -import React from 'react'; - -import { EuiComboBox, EuiHighlight, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { FieldIcon } from '../../../../../src/plugins/kibana_react/public'; - -function fieldsToOptions(fields) { - if (!fields) { - return []; - } - - return fields - .map((field) => { - return { - value: field, - label: 'label' in field ? field.label : field.name, - }; - }) - .sort((a, b) => { - return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); - }); -} - -function renderOption(option, searchValue, contentClassName) { - return ( - - - - - - {option.label} - - - ); -} - -export function SingleFieldSelect({ fields, onChange, value, placeholder, ...rest }) { - const onSelection = (selectedOptions) => { - onChange(_.get(selectedOptions, '0.value.name')); - }; - - return ( - - ); -} - -SingleFieldSelect.propTypes = { - placeholder: PropTypes.string, - fields: PropTypes.array, - onChange: PropTypes.func.isRequired, - value: PropTypes.string, // fieldName -}; diff --git a/x-pack/plugins/maps/public/components/single_field_select.tsx b/x-pack/plugins/maps/public/components/single_field_select.tsx new file mode 100644 index 00000000000000..eb3a28be0efc01 --- /dev/null +++ b/x-pack/plugins/maps/public/components/single_field_select.tsx @@ -0,0 +1,118 @@ +/* + * 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 React from 'react'; + +import { + EuiComboBox, + EuiComboBoxProps, + EuiComboBoxOptionOption, + EuiHighlight, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, +} from '@elastic/eui'; +import { IFieldType } from 'src/plugins/data/public'; +import { FieldIcon } from '../../../../../src/plugins/kibana_react/public'; + +function fieldsToOptions( + fields?: IFieldType[], + isFieldDisabled?: (field: IFieldType) => boolean +): Array> { + if (!fields) { + return []; + } + + return fields + .map((field) => { + const option: EuiComboBoxOptionOption = { + value: field, + label: field.name, + }; + if (isFieldDisabled && isFieldDisabled(field)) { + option.disabled = true; + } + return option; + }) + .sort((a, b) => { + return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); + }); +} + +type Props = Omit< + EuiComboBoxProps, + 'isDisabled' | 'onChange' | 'options' | 'renderOption' | 'selectedOptions' | 'singleSelection' +> & { + fields?: IFieldType[]; + onChange: (fieldName?: string) => void; + value?: string; // index pattern field name + isFieldDisabled?: (field: IFieldType) => boolean; + getFieldDisabledReason?: (field: IFieldType) => string | null; +}; + +export function SingleFieldSelect({ + fields, + getFieldDisabledReason, + isFieldDisabled, + onChange, + value, + ...rest +}: Props) { + function renderOption( + option: EuiComboBoxOptionOption, + searchValue: string, + contentClassName: string + ) { + const content = ( + + + + + + {option.label} + + + ); + + const disabledReason = + option.disabled && getFieldDisabledReason ? getFieldDisabledReason(option.value!) : null; + + return disabledReason ? ( + + {content} + + ) : ( + content + ); + } + + const onSelection = (selectedOptions: Array>) => { + onChange(_.get(selectedOptions, '0.value.name')); + }; + + const selectedOptions: Array> = []; + if (value && fields) { + const selectedField = fields.find((field: IFieldType) => { + return field.name === value; + }); + if (selectedField) { + selectedOptions.push({ value: selectedField, label: value }); + } + } + + return ( + + ); +} diff --git a/x-pack/plugins/maps/public/index_pattern_util.js b/x-pack/plugins/maps/public/index_pattern_util.js deleted file mode 100644 index 514feeaa220722..00000000000000 --- a/x-pack/plugins/maps/public/index_pattern_util.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 { getIndexPatternService, getIsGoldPlus } from './kibana_services'; -import { indexPatterns } from '../../../../src/plugins/data/public'; -import { ES_GEO_FIELD_TYPE } from '../common/constants'; - -export async function getIndexPatternsFromIds(indexPatternIds = []) { - const promises = []; - indexPatternIds.forEach((id) => { - const indexPatternPromise = getIndexPatternService().get(id); - if (indexPatternPromise) { - promises.push(indexPatternPromise); - } - }); - - return await Promise.all(promises); -} - -export function getTermsFields(fields) { - return fields.filter((field) => { - return ( - field.aggregatable && - !indexPatterns.isNestedField(field) && - ['number', 'boolean', 'date', 'ip', 'string'].includes(field.type) - ); - }); -} - -export function getAggregatableGeoFieldTypes() { - const aggregatableFieldTypes = [ES_GEO_FIELD_TYPE.GEO_POINT]; - if (getIsGoldPlus()) { - aggregatableFieldTypes.push(ES_GEO_FIELD_TYPE.GEO_SHAPE); - } - return aggregatableFieldTypes; -} - -export function getFieldsWithGeoTileAgg(fields) { - return fields.filter(supportsGeoTileAgg); -} - -export function supportsGeoTileAgg(field) { - return ( - field && - field.aggregatable && - !indexPatterns.isNestedField(field) && - getAggregatableGeoFieldTypes().includes(field.type) - ); -} - -// Returns filtered fields list containing only fields that exist in _source. -export function getSourceFields(fields) { - return fields.filter((field) => { - // Multi fields are not stored in _source and only exist in index. - const isMultiField = field.subType && field.subType.multi; - return !isMultiField && !indexPatterns.isNestedField(field); - }); -} diff --git a/x-pack/plugins/maps/public/index_pattern_util.test.js b/x-pack/plugins/maps/public/index_pattern_util.test.ts similarity index 93% rename from x-pack/plugins/maps/public/index_pattern_util.test.js rename to x-pack/plugins/maps/public/index_pattern_util.test.ts index 4fa9eb3cadb494..27b0a4aac9bf74 100644 --- a/x-pack/plugins/maps/public/index_pattern_util.test.js +++ b/x-pack/plugins/maps/public/index_pattern_util.test.ts @@ -18,6 +18,7 @@ describe('getSourceFields', () => { const fields = [ { name: 'agent', + type: 'string', }, { name: 'agent.keyword', @@ -26,10 +27,11 @@ describe('getSourceFields', () => { parent: 'agent', }, }, + type: 'string', }, ]; const sourceFields = getSourceFields(fields); - expect(sourceFields).toEqual([{ name: 'agent' }]); + expect(sourceFields).toEqual([{ name: 'agent', type: 'string' }]); }); }); @@ -37,6 +39,7 @@ describe('Gold+ licensing', () => { const testStubs = [ { field: { + name: 'location', type: 'geo_point', aggregatable: true, }, @@ -45,6 +48,7 @@ describe('Gold+ licensing', () => { }, { field: { + name: 'location', type: 'geo_shape', aggregatable: false, }, @@ -53,6 +57,7 @@ describe('Gold+ licensing', () => { }, { field: { + name: 'location', type: 'geo_shape', aggregatable: true, }, diff --git a/x-pack/plugins/maps/public/index_pattern_util.ts b/x-pack/plugins/maps/public/index_pattern_util.ts new file mode 100644 index 00000000000000..e65e37ef19809c --- /dev/null +++ b/x-pack/plugins/maps/public/index_pattern_util.ts @@ -0,0 +1,85 @@ +/* + * 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 { IFieldType, IndexPattern } from 'src/plugins/data/public'; +import { i18n } from '@kbn/i18n'; +import { getIndexPatternService, getIsGoldPlus } from './kibana_services'; +import { indexPatterns } from '../../../../src/plugins/data/public'; +import { ES_GEO_FIELD_TYPE, ES_GEO_FIELD_TYPES } from '../common/constants'; + +export function getGeoTileAggNotSupportedReason(field: IFieldType): string | null { + if (!field.aggregatable) { + return i18n.translate('xpack.maps.geoTileAgg.disabled.docValues', { + defaultMessage: + 'Clustering requires aggregations. Enable aggregations by setting doc_values to true.', + }); + } + + if (field.type === ES_GEO_FIELD_TYPE.GEO_SHAPE && !getIsGoldPlus()) { + return i18n.translate('xpack.maps.geoTileAgg.disabled.license', { + defaultMessage: 'Geo_shape clustering requires a Gold license.', + }); + } + + return null; +} + +export async function getIndexPatternsFromIds( + indexPatternIds: string[] = [] +): Promise { + const promises: Array> = []; + indexPatternIds.forEach((id) => { + promises.push(getIndexPatternService().get(id)); + }); + + return await Promise.all(promises); +} + +export function getTermsFields(fields: IFieldType[]): IFieldType[] { + return fields.filter((field) => { + return ( + field.aggregatable && + !indexPatterns.isNestedField(field) && + ['number', 'boolean', 'date', 'ip', 'string'].includes(field.type) + ); + }); +} + +export function getAggregatableGeoFieldTypes(): string[] { + const aggregatableFieldTypes = [ES_GEO_FIELD_TYPE.GEO_POINT]; + if (getIsGoldPlus()) { + aggregatableFieldTypes.push(ES_GEO_FIELD_TYPE.GEO_SHAPE); + } + return aggregatableFieldTypes; +} + +export function getGeoFields(fields: IFieldType[]): IFieldType[] { + return fields.filter((field) => { + return !indexPatterns.isNestedField(field) && ES_GEO_FIELD_TYPES.includes(field.type); + }); +} + +export function getFieldsWithGeoTileAgg(fields: IFieldType[]): IFieldType[] { + return fields.filter(supportsGeoTileAgg); +} + +export function supportsGeoTileAgg(field?: IFieldType): boolean { + return ( + !!field && + !!field.aggregatable && + !indexPatterns.isNestedField(field) && + getAggregatableGeoFieldTypes().includes(field.type) + ); +} + +// Returns filtered fields list containing only fields that exist in _source. +export function getSourceFields(fields: IFieldType[]): IFieldType[] { + return fields.filter((field) => { + // Multi fields are not stored in _source and only exist in index. + const isMultiField = field.subType && field.subType.multi; + return !isMultiField && !indexPatterns.isNestedField(field); + }); +}