diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index 0dddf982bcbc2..c572149f6b578 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -149,7 +149,8 @@ export function getSuggestions({ currentVisualizationState, subVisualizationId, palette, - visualizeTriggerFieldContext && 'isVisualizeAction' in visualizeTriggerFieldContext + visualizeTriggerFieldContext && 'isVisualizeAction' in visualizeTriggerFieldContext, + activeData ); }); }) @@ -207,7 +208,8 @@ function getVisualizationSuggestions( currentVisualizationState: unknown, subVisualizationId?: string, mainPalette?: PaletteOutput, - isFromContext?: boolean + isFromContext?: boolean, + activeData?: Record ) { return visualization .getSuggestions({ @@ -217,6 +219,7 @@ function getVisualizationSuggestions( subVisualizationId, mainPalette, isFromContext, + activeData, }) .map(({ state, ...visualizationSuggestion }) => ({ ...visualizationSuggestion, diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index e9a458f6e3a24..a86ba194bf4db 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -12,7 +12,15 @@ export type { TypedLensByValueInput, } from './embeddable/embeddable_component'; export type { XYState } from './xy_visualization/types'; -export type { DataType, OperationMetadata, Visualization } from './types'; +export type { + DatasourcePublicAPI, + DataType, + OperationMetadata, + SuggestionRequest, + TableSuggestion, + Visualization, + VisualizationSuggestion, +} from './types'; export type { AxesSettingsConfig, XYLayerConfig, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 058d850dee09c..0104a75dd99ab 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -646,6 +646,7 @@ export interface SuggestionRequest { * Different suggestions can be generated for each subtype of the visualization */ subVisualizationId?: string; + activeData?: Record; } /** diff --git a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts index e1b5d3e8190b0..a4a99360c41c0 100644 --- a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts @@ -158,6 +158,7 @@ export type TiledSingleLayerVectorSourceDescriptor = AbstractSourceDescriptor & export type InlineFieldDescriptor = { name: string; + label?: string; type: 'string' | 'number'; }; diff --git a/x-pack/plugins/maps/common/index.ts b/x-pack/plugins/maps/common/index.ts index 3e26cdc82fbbb..e140a855c3c30 100644 --- a/x-pack/plugins/maps/common/index.ts +++ b/x-pack/plugins/maps/common/index.ts @@ -22,6 +22,7 @@ export { MAX_ZOOM, MIN_ZOOM, VECTOR_SHAPE_TYPE, + VECTOR_STYLES, } from './constants'; export type { FieldFormatter } from './constants'; diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index e4c151d63ac1e..e049a0870855a 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -8,10 +8,12 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "maps"], "requiredPlugins": [ + "lens", "licensing", "features", "inspector", "data", + "fieldFormats", "fileUpload", "uiActions", "navigation", diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index c27007469af16..b081ed6d34979 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -17,6 +17,7 @@ import { getLayerListRaw, getMapColors, getMapReady, + getMapSettings, getSelectedLayerId, } from '../selectors/map_selectors'; import { FLYOUT_STATE } from '../reducers/ui'; @@ -35,12 +36,18 @@ import { SET_SELECTED_LAYER, SET_WAITING_FOR_READY_HIDDEN_LAYERS, TRACK_CURRENT_LAYER_STATE, + UPDATE_LAYER, UPDATE_LAYER_ORDER, UPDATE_LAYER_PROP, UPDATE_LAYER_STYLE, UPDATE_SOURCE_PROP, } from './map_action_constants'; -import { clearDataRequests, syncDataForLayerId, updateStyleMeta } from './data_request_actions'; +import { + autoFitToBounds, + clearDataRequests, + syncDataForLayerId, + updateStyleMeta, +} from './data_request_actions'; import { updateTooltipStateForLayer } from './tooltip_actions'; import { Attribution, @@ -117,6 +124,22 @@ export function replaceLayerList(newLayerList: LayerDescriptor[]) { }; } +export function updateLayerById(layerDescriptor: LayerDescriptor) { + return async ( + dispatch: ThunkDispatch, + getState: () => MapStoreState + ) => { + dispatch({ + type: UPDATE_LAYER, + layer: layerDescriptor, + }); + await dispatch(syncDataForLayerId(layerDescriptor.id, false)); + if (getMapSettings(getState()).autoFitToDataBounds) { + dispatch(autoFitToBounds()); + } + }; +} + export function cloneLayer(layerId: string) { return async ( dispatch: ThunkDispatch, diff --git a/x-pack/plugins/maps/public/actions/map_action_constants.ts b/x-pack/plugins/maps/public/actions/map_action_constants.ts index 9f8e87889310e..bd2e9f076e971 100644 --- a/x-pack/plugins/maps/public/actions/map_action_constants.ts +++ b/x-pack/plugins/maps/public/actions/map_action_constants.ts @@ -23,6 +23,7 @@ export const LAYER_DATA_LOAD_ERROR = 'LAYER_DATA_LOAD_ERROR'; export const UPDATE_SOURCE_DATA_REQUEST = 'UPDATE_SOURCE_DATA_REQUEST'; export const SET_JOINS = 'SET_JOINS'; export const SET_QUERY = 'SET_QUERY'; +export const UPDATE_LAYER = 'UPDATE_LAYER'; export const UPDATE_LAYER_PROP = 'UPDATE_LAYER_PROP'; export const UPDATE_LAYER_STYLE = 'UPDATE_LAYER_STYLE'; export const SET_LAYER_STYLE_META = 'SET_LAYER_STYLE_META'; diff --git a/x-pack/plugins/maps/public/classes/fields/inline_field.ts b/x-pack/plugins/maps/public/classes/fields/inline_field.ts index 1c81d1399f24b..f6bf3a17cd6d4 100644 --- a/x-pack/plugins/maps/public/classes/fields/inline_field.ts +++ b/x-pack/plugins/maps/public/classes/fields/inline_field.ts @@ -10,21 +10,25 @@ import { IField, AbstractField } from './field'; import { IVectorSource } from '../sources/vector_source'; export class InlineField extends AbstractField implements IField { + private readonly _label?: string; private readonly _source: T; private readonly _dataType: string; constructor({ fieldName, + label, source, origin, dataType, }: { fieldName: string; + label?: string; source: T; origin: FIELD_ORIGIN; dataType: string; }) { super({ fieldName, origin }); + this._label = label; this._source = source; this._dataType = dataType; } @@ -42,7 +46,7 @@ export class InlineField extends AbstractField implemen } async getLabel(): Promise { - return this.getName(); + return this._label ? this._label : this.getName(); } async getDataType(): Promise { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_vector_layer.tsx index ebcd4e23c5375..42303bc0b4c31 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_vector_layer.tsx @@ -247,7 +247,7 @@ export class GeoJsonVectorLayer extends AbstractVectorLayer { } const joinStates = await this._syncJoins(syncContext, style); - performInnerJoins( + await performInnerJoins( sourceResult, joinStates, syncContext.updateSourceData, diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.test.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.test.ts index 1049c4373c933..1a7dfbcfb2a5e 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.test.ts @@ -8,12 +8,8 @@ import sinon from 'sinon'; import _ from 'lodash'; import { FeatureCollection } from 'geojson'; -import { ESTermSourceDescriptor } from '../../../../../common/descriptor_types'; -import { - AGG_TYPE, - FEATURE_VISIBLE_PROPERTY_NAME, - SOURCE_TYPES, -} from '../../../../../common/constants'; +import { TableSourceDescriptor } from '../../../../../common/descriptor_types'; +import { FEATURE_VISIBLE_PROPERTY_NAME, SOURCE_TYPES } from '../../../../../common/constants'; import { performInnerJoins } from './perform_inner_joins'; import { InnerJoin } from '../../../joins/inner_join'; import { IVectorSource } from '../../../sources/vector_source'; @@ -53,18 +49,21 @@ const featureCollection = { const joinDescriptor = { leftField: LEFT_FIELD, right: { - applyGlobalQuery: true, - applyGlobalTime: true, id: 'd3625663-5b34-4d50-a784-0d743f676a0c', - indexPatternId: 'myIndexPattern', - metrics: [ + __rows: [], + __columns: [ { - type: AGG_TYPE.COUNT, + name: 'rightKey', + type: 'string', + }, + { + name: COUNT_PROPERTY_NAME, + type: 'number', }, ], term: 'rightKey', - type: SOURCE_TYPES.ES_TERM_SOURCE, - } as ESTermSourceDescriptor, + type: SOURCE_TYPES.TABLE_SOURCE, + } as TableSourceDescriptor, }; const mockVectorSource = { getInspectorAdapters: () => { @@ -75,18 +74,21 @@ const mockVectorSource = { getName: () => { return LEFT_FIELD; }, - } as IField; + getLabel: () => { + return LEFT_FIELD; + }, + } as unknown as IField; }, } as unknown as IVectorSource; const innerJoin = new InnerJoin(joinDescriptor, mockVectorSource); const propertiesMap = new Map>(); propertiesMap.set('alpha', { [COUNT_PROPERTY_NAME]: 1 }); -test('should skip join when no state has changed', () => { +test('should skip join when no state has changed', async () => { const updateSourceData = sinon.spy(); const onJoinError = sinon.spy(); - performInnerJoins( + await performInnerJoins( { refreshed: false, featureCollection: _.cloneDeep(featureCollection) as FeatureCollection, @@ -105,11 +107,11 @@ test('should skip join when no state has changed', () => { expect(onJoinError.notCalled); }); -test('should perform join when features change', () => { +test('should perform join when features change', async () => { const updateSourceData = sinon.spy(); const onJoinError = sinon.spy(); - performInnerJoins( + await performInnerJoins( { refreshed: true, featureCollection: _.cloneDeep(featureCollection) as FeatureCollection, @@ -128,11 +130,11 @@ test('should perform join when features change', () => { expect(onJoinError.notCalled); }); -test('should perform join when join state changes', () => { +test('should perform join when join state changes', async () => { const updateSourceData = sinon.spy(); const onJoinError = sinon.spy(); - performInnerJoins( + await performInnerJoins( { refreshed: false, featureCollection: _.cloneDeep(featureCollection) as FeatureCollection, @@ -151,11 +153,11 @@ test('should perform join when join state changes', () => { expect(onJoinError.notCalled); }); -test('should call updateSourceData with feature collection with updated feature visibility and join properties', () => { +test('should call updateSourceData with feature collection with updated feature visibility and join properties', async () => { const updateSourceData = sinon.spy(); const onJoinError = sinon.spy(); - performInnerJoins( + await performInnerJoins( { refreshed: true, featureCollection: _.cloneDeep(featureCollection) as FeatureCollection, @@ -204,11 +206,11 @@ test('should call updateSourceData with feature collection with updated feature expect(onJoinError.notCalled); }); -test('should call updateSourceData when no results returned from terms aggregation', () => { +test('should call updateSourceData when no results returned from terms aggregation', async () => { const updateSourceData = sinon.spy(); const onJoinError = sinon.spy(); - performInnerJoins( + await performInnerJoins( { refreshed: false, featureCollection: _.cloneDeep(featureCollection) as FeatureCollection, @@ -256,7 +258,7 @@ test('should call updateSourceData when no results returned from terms aggregati expect(onJoinError.notCalled); }); -test('should call onJoinError when there are no matching features', () => { +test('should call onJoinError when there are no matching features', async () => { const updateSourceData = sinon.spy(); const onJoinError = sinon.spy(); @@ -264,7 +266,7 @@ test('should call onJoinError when there are no matching features', () => { const propertiesMapFromMismatchedKey = new Map>(); propertiesMapFromMismatchedKey.set('1', { [COUNT_PROPERTY_NAME]: 1 }); - performInnerJoins( + await performInnerJoins( { refreshed: false, featureCollection: _.cloneDeep(featureCollection) as FeatureCollection, diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.ts index 3dd2a5ddb377e..d02f1c1a99bb7 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/perform_inner_joins.ts @@ -23,7 +23,7 @@ export interface JoinState { propertiesMap?: PropertiesMap; } -export function performInnerJoins( +export async function performInnerJoins( sourceResult: SourceResult, joinStates: JoinState[], updateSourceData: DataRequestContext['updateSourceData'], @@ -102,7 +102,11 @@ export function performInnerJoins( } const joinStatus = joinStatusesWithoutAnyMatches[0]; - const leftFieldName = joinStatus.joinState.join.getLeftField().getName(); + const leftFieldName = await joinStatus.joinState.join.getLeftField().getLabel(); + const rightFieldName = await joinStatus.joinState.join + .getRightJoinSource() + .getTermField() + .getLabel(); const reason = joinStatus.keys.length === 0 ? i18n.translate('xpack.maps.vectorLayer.joinError.noLeftFieldValuesMsg', { @@ -114,10 +118,7 @@ export function performInnerJoins( values: { leftFieldName, leftFieldValues: prettyPrintArray(joinStatus.keys), - rightFieldName: joinStatus.joinState.join - .getRightJoinSource() - .getTermField() - .getName(), + rightFieldName, rightFieldValues: prettyPrintArray( Array.from(joinStatus.joinState.propertiesMap!.keys()) ), diff --git a/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts b/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts index c48a25219874a..768c7cacffab3 100644 --- a/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts @@ -6,6 +6,7 @@ */ import uuid from 'uuid'; +import { GeoJsonProperties } from 'geojson'; import type { Query } from 'src/plugins/data/common'; import { FIELD_ORIGIN, SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { @@ -27,6 +28,7 @@ import { } from '../vector_source'; import { DataRequest } from '../../util/data_request'; import { InlineField } from '../../fields/inline_field'; +import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property'; export class TableSource extends AbstractVectorSource implements ITermJoinSource, IVectorSource { static type = SOURCE_TYPES.TABLE_SOURCE; @@ -106,6 +108,7 @@ export class TableSource extends AbstractVectorSource implements ITermJoinSource return new InlineField({ fieldName: column.name, + label: column.label, source: this, origin: FIELD_ORIGIN.JOIN, dataType: column.type, @@ -128,6 +131,7 @@ export class TableSource extends AbstractVectorSource implements ITermJoinSource return this._descriptor.__columns.map((column) => { return new InlineField({ fieldName: column.name, + label: column.label, source: this, origin: FIELD_ORIGIN.JOIN, dataType: column.type, @@ -171,6 +175,7 @@ export class TableSource extends AbstractVectorSource implements ITermJoinSource return new InlineField({ fieldName: column.name, + label: column.label, source: this, origin: FIELD_ORIGIN.JOIN, dataType: column.type, @@ -207,4 +212,17 @@ export class TableSource extends AbstractVectorSource implements ITermJoinSource isBoundsAware(): boolean { return false; } + + async getTooltipProperties(properties: GeoJsonProperties): Promise { + const tooltipProperties: ITooltipProperty[] = []; + for (const key in properties) { + if (properties.hasOwnProperty(key)) { + const field = this.getFieldByName(key); + if (field) { + tooltipProperties.push(new TooltipProperty(key, await field.getLabel(), properties[key])); + } + } + } + return tooltipProperties; + } } diff --git a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/index.ts b/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/index.ts new file mode 100644 index 0000000000000..4e48d96b032fe --- /dev/null +++ b/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { JoinTooltipProperty } from './join_tooltip_property'; diff --git a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_key_label.tsx b/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_key_label.tsx new file mode 100644 index 0000000000000..624b7515c04c6 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_key_label.tsx @@ -0,0 +1,77 @@ +/* + * 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. + */ + +/* + * 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 { asyncMap } from '@kbn/std'; +import { i18n } from '@kbn/i18n'; +import { EuiIcon, EuiToolTip } from '@elastic/eui'; +import { InnerJoin } from '../../joins/inner_join'; + +interface Props { + leftFieldName: string; + innerJoins: InnerJoin[]; +} + +interface State { + rightSourceLabels: string[]; +} + +export class JoinKeyLabel extends Component { + private _isMounted = false; + + state: State = { rightSourceLabels: [] }; + + componentDidMount() { + this._isMounted = true; + this._loadRightSourceLabels(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadRightSourceLabels() { + const rightSourceLabels = await asyncMap(this.props.innerJoins, async (innerJoin) => { + const rightSource = innerJoin.getRightJoinSource(); + const termField = rightSource.getTermField(); + return `'${await termField.getLabel()}'`; + }); + + if (this._isMounted) { + this.setState({ rightSourceLabels }); + } + } + + render() { + if (this.state.rightSourceLabels.length === 0) { + return this.props.leftFieldName; + } + + const content = i18n.translate('xpack.maps.tooltip.joinPropertyTooltipContent', { + defaultMessage: `Shared key '{leftFieldName}' is joined with {rightSources}`, + values: { + leftFieldName: this.props.leftFieldName, + rightSources: this.state.rightSourceLabels.join(','), + }, + }); + return ( + <> + {this.props.leftFieldName} + + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.tsx b/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_tooltip_property.tsx similarity index 66% rename from x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.tsx rename to x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_tooltip_property.tsx index 5de9eb5aba77e..30793572b4f6a 100644 --- a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.tsx +++ b/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property/join_tooltip_property.tsx @@ -6,11 +6,10 @@ */ import React, { ReactNode } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiToolTip } from '@elastic/eui'; import { Filter } from '@kbn/es-query'; -import { ITooltipProperty } from './tooltip_property'; -import { InnerJoin } from '../joins/inner_join'; +import { ITooltipProperty } from '../tooltip_property'; +import { InnerJoin } from '../../joins/inner_join'; +import { JoinKeyLabel } from './join_key_label'; export class JoinTooltipProperty implements ITooltipProperty { private readonly _tooltipProperty: ITooltipProperty; @@ -30,26 +29,11 @@ export class JoinTooltipProperty implements ITooltipProperty { } getPropertyName(): ReactNode { - const content = i18n.translate('xpack.maps.tooltip.joinPropertyTooltipContent', { - defaultMessage: `Shared key '{leftFieldName}' is joined with {rightSources}`, - values: { - leftFieldName: this._tooltipProperty.getPropertyName() as string, - rightSources: this._innerJoins - .map((innerJoin) => { - const rightSource = innerJoin.getRightJoinSource(); - const termField = rightSource.getTermField(); - return `'${termField.getName()}'`; - }) - .join(','), - }, - }); return ( - <> - {this._tooltipProperty.getPropertyName()} - - - - + ); } diff --git a/x-pack/plugins/maps/public/components/ems_file_select.tsx b/x-pack/plugins/maps/public/components/ems_file_select.tsx index 3d23854efb4fb..694e3f6413059 100644 --- a/x-pack/plugins/maps/public/components/ems_file_select.tsx +++ b/x-pack/plugins/maps/public/components/ems_file_select.tsx @@ -14,6 +14,7 @@ import { getEmsFileLayers } from '../util'; import { getEmsUnavailableMessage } from './ems_unavailable_message'; interface Props { + isColumnCompressed?: boolean; onChange: (emsFileId: string) => void; value: string | null; } @@ -78,7 +79,7 @@ export class EMSFileSelect extends Component { return ( { return ( {this._renderSelect()} diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index e4903ad8b70f4..54d6d546ee46a 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -38,6 +38,7 @@ import { setQuery, disableScrollZoom, setReadOnly, + updateLayerById, } from '../actions'; import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors'; import { @@ -419,6 +420,10 @@ export class MapEmbeddable }); } + updateLayerById(layerDescriptor: LayerDescriptor) { + this._savedMap.getStore().dispatch(updateLayerById(layerDescriptor)); + } + private async _getIndexPatterns() { const queryableIndexPatternIds = getQueryableUniqueIndexPatternIds( this._savedMap.getStore().getState() diff --git a/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.test.ts b/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.test.ts index cc0ed19db0b40..47f488a3de02b 100644 --- a/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.test.ts +++ b/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.test.ts @@ -20,6 +20,10 @@ class MockFileLayer { return this._id; } + getDisplayName() { + return `display name: ${this._id}`; + } + getFields() { return this._fields; } @@ -62,6 +66,7 @@ describe('suggestEMSTermJoinConfig', () => { sampleValuesColumnName: 'country_iso_code', }); expect(termJoinConfig).toEqual({ + displayName: 'display name: world_countries', layerId: 'world_countries', field: 'iso2', }); @@ -88,6 +93,7 @@ describe('suggestEMSTermJoinConfig', () => { sampleValuesColumnName: 'Country_name', }); expect(termJoinConfig).toEqual({ + displayName: 'display name: world_countries', layerId: 'world_countries', field: 'iso2', }); @@ -107,6 +113,7 @@ describe('suggestEMSTermJoinConfig', () => { sampleValues: ['40205', 40204], }); expect(termJoinConfig).toEqual({ + displayName: 'display name: usa_zip_codes', layerId: 'usa_zip_codes', field: 'zip', }); diff --git a/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts b/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts index 66fcbd805f53e..b88305cae0e92 100644 --- a/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts +++ b/x-pack/plugins/maps/public/ems_autosuggest/ems_autosuggest.ts @@ -16,6 +16,7 @@ export interface SampleValuesConfig { export interface EMSTermJoinConfig { layerId: string; field: string; + displayName: string; } interface UniqueMatch { @@ -32,10 +33,19 @@ interface FileLayerFieldShim { export async function suggestEMSTermJoinConfig( sampleValuesConfig: SampleValuesConfig ): Promise { + const fileLayers = await getEmsFileLayers(); + return emsAutoSuggest(sampleValuesConfig, fileLayers); +} + +export function emsAutoSuggest( + sampleValuesConfig: SampleValuesConfig, + fileLayers: FileLayer[] +): EMSTermJoinConfig | null { const matches: EMSTermJoinConfig[] = []; if (sampleValuesConfig.sampleValuesColumnName) { - const matchesBasedOnColumnName = await suggestByName( + const matchesBasedOnColumnName = suggestByName( + fileLayers, sampleValuesConfig.sampleValuesColumnName, sampleValuesConfig.sampleValues ); @@ -44,7 +54,7 @@ export async function suggestEMSTermJoinConfig( if (sampleValuesConfig.sampleValues && sampleValuesConfig.sampleValues.length) { // Only looks at id-values in main manifest - const matchesBasedOnIds = await suggestByIdValues(sampleValuesConfig.sampleValues); + const matchesBasedOnIds = suggestByIdValues(fileLayers, sampleValuesConfig.sampleValues); matches.push(...matchesBasedOnIds); } @@ -72,12 +82,11 @@ export async function suggestEMSTermJoinConfig( return uniqMatches.length ? uniqMatches[0].config : null; } -async function suggestByName( +function suggestByName( + fileLayers: FileLayer[], columnName: string, sampleValues?: Array -): Promise { - const fileLayers = await getEmsFileLayers(); - +): EMSTermJoinConfig[] { const matches: EMSTermJoinConfig[] = []; fileLayers.forEach((fileLayer) => { const emsFields: FileLayerFieldShim[] = fileLayer.getFields(); @@ -89,6 +98,7 @@ async function suggestByName( const emsConfig = { layerId: fileLayer.getId(), field: emsField.id, + displayName: fileLayer.getDisplayName(), }; emsField.alias.forEach((alias: string) => { const regex = new RegExp(alias, 'i'); @@ -127,23 +137,23 @@ function allSamplesMatch(sampleValues: Array, ids: string[]) { return true; } -async function suggestByIdValues( +function suggestByIdValues( + fileLayers: FileLayer[], sampleValues: Array -): Promise { +): EMSTermJoinConfig[] { const matches: EMSTermJoinConfig[] = []; - const fileLayers: FileLayer[] = await getEmsFileLayers(); fileLayers.forEach((fileLayer) => { const emsFields: FileLayerFieldShim[] = fileLayer.getFields(); emsFields.forEach((emsField: FileLayerFieldShim) => { if (!emsField.values || !emsField.values.length) { return; } - const emsConfig = { - layerId: fileLayer.getId(), - field: emsField.id, - }; if (allSamplesMatch(sampleValues, emsField.values)) { - matches.push(emsConfig); + matches.push({ + layerId: fileLayer.getId(), + field: emsField.id, + displayName: fileLayer.getDisplayName(), + }); } }); }); diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx new file mode 100644 index 0000000000000..4fd6dc8994b8c --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx @@ -0,0 +1,148 @@ +/* + * 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 type { FileLayer } from '@elastic/ems-client'; +import { IUiSettingsClient } from 'kibana/public'; +import type { EmbeddableFactory } from 'src/plugins/embeddable/public'; +import type { Datatable } from 'src/plugins/expressions/public'; +import type { FormatFactory } from 'src/plugins/field_formats/common'; +import { + FIELD_ORIGIN, + LAYER_TYPE, + SOURCE_TYPES, + STYLE_TYPE, + COLOR_MAP_TYPE, + VECTOR_STYLES, +} from '../../../common'; +import { emsWorldLayerId } from '../../../common/constants'; +import { ChoroplethChartProps } from './types'; +import { getEmsSuggestion } from './get_ems_suggestion'; +import { PassiveMap } from '../passive_map'; +import type { MapEmbeddableInput, MapEmbeddableOutput } from '../../embeddable'; + +interface Props extends ChoroplethChartProps { + formatFactory: FormatFactory; + uiSettings: IUiSettingsClient; + emsFileLayers: FileLayer[]; + mapEmbeddableFactory: EmbeddableFactory; +} + +export function ChoroplethChart({ + data, + args, + formatFactory, + uiSettings, + emsFileLayers, + mapEmbeddableFactory, +}: Props) { + if (!args.regionAccessor || !args.valueAccessor) { + return null; + } + + const table = data.tables[args.layerId]; + + let emsLayerId = args.emsLayerId ? args.emsLayerId : emsWorldLayerId; + let emsField = args.emsField ? args.emsField : 'iso2'; + if (!args.emsLayerId || !args.emsField) { + const emsSuggestion = getEmsSuggestion(emsFileLayers, table, args.regionAccessor); + if (emsSuggestion) { + emsLayerId = emsSuggestion.layerId; + emsField = emsSuggestion.field; + } + } + + const emsLayerLabel = getEmsLayerLabel(emsLayerId, emsFileLayers); + + const choroplethLayer = { + id: args.layerId, + label: emsLayerLabel + ? i18n.translate('xpack.maps.lens.choroplethChart.choroplethLayerLabel', { + defaultMessage: '{emsLayerLabel} by {accessorLabel}', + values: { + emsLayerLabel, + accessorLabel: getAccessorLabel(table, args.valueAccessor), + }, + }) + : '', + joins: [ + { + leftField: emsField, + right: { + id: args.valueAccessor, + type: SOURCE_TYPES.TABLE_SOURCE, + __rows: table.rows, + __columns: [ + { + name: args.regionAccessor, + label: getAccessorLabel(table, args.regionAccessor), + type: 'string', + }, + { + name: args.valueAccessor, + label: getAccessorLabel(table, args.valueAccessor), + type: 'number', + }, + ], + // Right join/term is the field in the doc you’re trying to join it to (foreign key - e.g. US) + term: args.regionAccessor, + }, + }, + ], + sourceDescriptor: { + type: SOURCE_TYPES.EMS_FILE, + id: emsLayerId, + tooltipProperties: [emsField], + }, + style: { + type: 'VECTOR', + // @ts-ignore missing style properties. Remove once 'VectorLayerDescriptor' type is updated + properties: { + [VECTOR_STYLES.FILL_COLOR]: { + type: STYLE_TYPE.DYNAMIC, + options: { + color: 'Blue to Red', + colorCategory: 'palette_0', + fieldMetaOptions: { isEnabled: true, sigma: 3 }, + type: COLOR_MAP_TYPE.ORDINAL, + field: { + name: args.valueAccessor, + origin: FIELD_ORIGIN.JOIN, + }, + useCustomColorRamp: false, + }, + }, + [VECTOR_STYLES.LINE_COLOR]: { + type: STYLE_TYPE.STATIC, + options: { + color: '#3d3d3d', + }, + }, + [VECTOR_STYLES.LINE_WIDTH]: { type: STYLE_TYPE.STATIC, options: { size: 1 } }, + }, + isTimeAware: false, + }, + type: LAYER_TYPE.GEOJSON_VECTOR, + }; + + return ; +} + +function getAccessorLabel(table: Datatable, accessor: string) { + const column = table.columns.find((col) => { + return col.id === accessor; + }); + return column ? column.name : accessor; +} + +function getEmsLayerLabel(emsLayerId: string, emsFileLayers: FileLayer[]): string | null { + const fileLayer = emsFileLayers.find((file) => { + return file.getId() === emsLayerId; + }); + return fileLayer ? fileLayer.getDisplayName() : null; +} diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts new file mode 100644 index 0000000000000..9b2f06d888b07 --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts @@ -0,0 +1,69 @@ +/* + * 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 type { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import type { LensMultiTable } from '../../../../lens/common'; +import type { ChoroplethChartConfig, ChoroplethChartProps } from './types'; +import { RENDERER_ID } from './expression_renderer'; + +interface ChoroplethChartRender { + type: 'render'; + as: 'lens_choropleth_chart_renderer'; + value: ChoroplethChartProps; +} + +export const getExpressionFunction = (): ExpressionFunctionDefinition< + 'lens_choropleth_chart', + LensMultiTable, + Omit, + ChoroplethChartRender +> => ({ + name: 'lens_choropleth_chart', + type: 'render', + help: 'A choropleth chart. Metrics are joined to vector features to compare values across political boundaries', + args: { + title: { + types: ['string'], + help: '', + }, + description: { + types: ['string'], + help: '', + }, + layerId: { + types: ['string'], + help: '', + }, + emsField: { + types: ['string'], + help: 'Elastic Map Service boundaries layer field provides the vector feature join key', + }, + emsLayerId: { + types: ['string'], + help: 'Elastic Map Service boundaries layer id that provides vector features', + }, + regionAccessor: { + types: ['string'], + help: 'Bucket accessor identifies the region key column', + }, + valueAccessor: { + types: ['string'], + help: 'Value accessor identifies the value column', + }, + }, + inputTypes: ['lens_multitable'], + fn(data, args) { + return { + type: 'render', + as: RENDERER_ID, + value: { + data, + args, + }, + } as ChoroplethChartRender; + }, +}); diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx new file mode 100644 index 0000000000000..0f1649d911c10 --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx @@ -0,0 +1,59 @@ +/* + * 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 ReactDOM from 'react-dom'; +import type { IInterpreterRenderHandlers } from 'src/plugins/expressions/public'; +import type { EmbeddableFactory } from 'src/plugins/embeddable/public'; +import type { CoreSetup, CoreStart } from 'src/core/public'; +import type { MapsPluginStartDependencies } from '../../plugin'; +import type { ChoroplethChartProps } from './types'; +import type { MapEmbeddableInput, MapEmbeddableOutput } from '../../embeddable'; + +export const RENDERER_ID = 'lens_choropleth_chart_renderer'; + +export function getExpressionRenderer(coreSetup: CoreSetup) { + return { + name: RENDERER_ID, + displayName: 'Choropleth chart', + help: 'Choropleth chart renderer', + validate: () => undefined, + reuseDomNode: true, + render: async ( + domNode: Element, + config: ChoroplethChartProps, + handlers: IInterpreterRenderHandlers + ) => { + const [coreStart, plugins]: [CoreStart, MapsPluginStartDependencies, unknown] = + await coreSetup.getStartServices(); + const { ChoroplethChart } = await import('./choropleth_chart'); + const { getEmsFileLayers } = await import('../../util'); + + const mapEmbeddableFactory = plugins.embeddable.getEmbeddableFactory( + 'map' + ) as EmbeddableFactory; + if (!mapEmbeddableFactory) { + return; + } + + ReactDOM.render( + , + domNode, + () => { + handlers.done(); + } + ); + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, + }; +} diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/get_ems_suggestion.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/get_ems_suggestion.ts new file mode 100644 index 0000000000000..6d8ee4148271e --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/get_ems_suggestion.ts @@ -0,0 +1,25 @@ +/* + * 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 type { FileLayer } from '@elastic/ems-client'; +import type { Datatable } from 'src/plugins/expressions/public'; +import { emsAutoSuggest } from '../../ems_autosuggest'; + +export function getEmsSuggestion( + emsFileLayers: FileLayer[], + table: Datatable, + regionAccessor: string +) { + const keys: string[] = []; + table.rows.forEach((row) => { + const key = row[regionAccessor]; + if (key && key !== '__other__' && !keys.includes(key)) { + keys.push(key); + } + }); + return emsAutoSuggest({ sampleValues: keys }, emsFileLayers); +} diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/icon.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/icon.tsx new file mode 100644 index 0000000000000..ebe0457d8c67c --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/icon.tsx @@ -0,0 +1,31 @@ +/* + * 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'; +import type { EuiIconProps } from '@elastic/eui'; + +export const Icon: FunctionComponent = ({ + title, + titleId, + ...props +}: Omit) => ( + + {title ? : null} + + + + +); diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/index.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/index.ts new file mode 100644 index 0000000000000..504db6f6cf08b --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { setupLensChoroplethChart } from './setup'; diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/region_key_editor.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/region_key_editor.tsx new file mode 100644 index 0000000000000..6d0f516e5642c --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/region_key_editor.tsx @@ -0,0 +1,101 @@ +/* + * 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 { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; +import type { FileLayer } from '@elastic/ems-client'; +import { ChoroplethChartState } from './types'; +import { EMSFileSelect } from '../../components/ems_file_select'; + +interface Props { + emsFileLayers: FileLayer[]; + state: ChoroplethChartState; + setState: (state: ChoroplethChartState) => void; +} + +export function RegionKeyEditor(props: Props) { + function onEmsLayerSelect(emsLayerId: string) { + const emsFields = getEmsFields(props.emsFileLayers, emsLayerId); + props.setState({ + ...props.state, + emsLayerId, + emsField: emsFields.length ? emsFields[0].value : undefined, + }); + } + + function onEmsFieldSelect(selectedOptions: Array>) { + if (selectedOptions.length === 0) { + return; + } + + props.setState({ + ...props.state, + emsField: selectedOptions[0].value, + }); + } + + let emsFieldSelect; + const emsFields = getEmsFields(props.emsFileLayers, props.state.emsLayerId); + if (emsFields.length) { + let selectedOption; + if (props.state.emsField) { + selectedOption = emsFields.find((option: EuiComboBoxOptionOption) => { + return props.state.emsField === option.value; + }); + } + emsFieldSelect = ( + + + + ); + } + return ( + <> + + {emsFieldSelect} + + ); +} + +function getEmsFields(emsFileLayers: FileLayer[], emsLayerId?: string) { + if (!emsLayerId) { + return []; + } + const emsFileLayer = emsFileLayers.find((fileLayer: FileLayer) => { + return fileLayer.getId() === emsLayerId; + }); + + return emsFileLayer + ? emsFileLayer + .getFieldsInLanguage() + .filter((field) => { + return field.type === 'id'; + }) + .map((field) => { + return { + value: field.name, + label: field.description, + }; + }) + : []; +} diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/setup.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/setup.ts new file mode 100644 index 0000000000000..69d575c6f9a8f --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/setup.ts @@ -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 type { ExpressionsSetup } from 'src/plugins/expressions/public'; +import type { CoreSetup, CoreStart } from 'src/core/public'; +import type { LensPublicSetup } from '../../../../lens/public'; +import type { MapsPluginStartDependencies } from '../../plugin'; +import { getExpressionFunction } from './expression_function'; +import { getExpressionRenderer } from './expression_renderer'; + +export function setupLensChoroplethChart( + coreSetup: CoreSetup, + expressions: ExpressionsSetup, + lens: LensPublicSetup +) { + expressions.registerRenderer(() => { + return getExpressionRenderer(coreSetup); + }); + + expressions.registerFunction(getExpressionFunction); + + lens.registerVisualization(async () => { + const [coreStart, plugins]: [CoreStart, MapsPluginStartDependencies, unknown] = + await coreSetup.getStartServices(); + const { getEmsFileLayers } = await import('../../util'); + const { getVisualization } = await import('./visualization'); + return getVisualization({ + theme: coreStart.theme, + emsFileLayers: await getEmsFileLayers(), + paletteService: await plugins.charts.palettes.getPalettes(), + }); + }); +} diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/suggestions.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/suggestions.ts new file mode 100644 index 0000000000000..501cc44567bac --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/suggestions.ts @@ -0,0 +1,83 @@ +/* + * 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 { partition } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import type { FileLayer } from '@elastic/ems-client'; +import type { SuggestionRequest, VisualizationSuggestion } from '../../../../lens/public'; +import type { ChoroplethChartState } from './types'; +import { Icon } from './icon'; +import { getEmsSuggestion } from './get_ems_suggestion'; + +/** + * Generate choroplath chart suggestions for buckets that match administrative boundaries from the Elastic Maps Service. + */ +export function getSuggestions( + suggestionRequest: SuggestionRequest, + emsFileLayers: FileLayer[] +): Array> { + const { activeData, keptLayerIds, state, table } = suggestionRequest; + + if (!activeData) { + return []; + } + + const isUnchanged = state && table.changeType === 'unchanged'; + if ( + isUnchanged || + keptLayerIds.length > 1 || + (keptLayerIds.length && table.layerId !== keptLayerIds[0]) + ) { + return []; + } + + const [buckets, metrics] = partition(table.columns, (col) => col.operation.isBucketed); + + if (buckets.length !== 1 || metrics.length !== 1) { + return []; + } + + const metric = metrics[0]; + const suggestions: Array> = []; + buckets + .filter((col) => { + return col.operation.dataType === 'string'; + }) + .forEach((bucket) => { + for (const tableId in activeData) { + if (activeData.hasOwnProperty(tableId)) { + const emsSuggestion = getEmsSuggestion( + emsFileLayers, + activeData[tableId], + bucket.columnId + ); + if (emsSuggestion) { + suggestions.push({ + title: i18n.translate('xpack.maps.lens.choroplethChart.suggestionLabel', { + defaultMessage: '{emsLayerLabel} by {metricLabel}', + values: { + emsLayerLabel: emsSuggestion.displayName, + metricLabel: metric.operation.label.toLowerCase(), + }, + }), + score: 0.7, + state: { + layerId: tableId, + emsLayerId: emsSuggestion.layerId, + emsField: emsSuggestion.field, + valueAccessor: metric.columnId, + regionAccessor: bucket.columnId, + }, + previewIcon: Icon, + }); + } + } + } + }); + + return suggestions; +} diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts new file mode 100644 index 0000000000000..b9b7f3912f0cc --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts @@ -0,0 +1,26 @@ +/* + * 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 type { LensMultiTable } from '../../../../lens/common'; + +export interface ChoroplethChartState { + layerId: string; + emsLayerId?: string; + emsField?: string; + regionAccessor?: string; + valueAccessor?: string; +} + +export interface ChoroplethChartConfig extends ChoroplethChartState { + title: string; + description: string; +} + +export interface ChoroplethChartProps { + data: LensMultiTable; + args: ChoroplethChartConfig; +} diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx new file mode 100644 index 0000000000000..93c3bec52471e --- /dev/null +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx @@ -0,0 +1,216 @@ +/* + * 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 { I18nProvider } from '@kbn/i18n-react'; +import { render } from 'react-dom'; +import type { FileLayer } from '@elastic/ems-client'; +import { ThemeServiceStart } from 'kibana/public'; +import type { PaletteRegistry } from 'src/plugins/charts/public'; +import { KibanaThemeProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { getSuggestions } from './suggestions'; +import { layerTypes } from '../../../../lens/public'; +import type { OperationMetadata, SuggestionRequest, Visualization } from '../../../../lens/public'; +import type { ChoroplethChartState } from './types'; +import { Icon } from './icon'; +import { RegionKeyEditor } from './region_key_editor'; + +const REGION_KEY_GROUP_ID = 'region_key'; +const METRIC_GROUP_ID = 'metric'; + +const CHART_LABEL = i18n.translate('xpack.maps.lens.choropleth.label', { + defaultMessage: 'Region map', +}); + +export const getVisualization = ({ + paletteService, + theme, + emsFileLayers, +}: { + paletteService: PaletteRegistry; + theme: ThemeServiceStart; + emsFileLayers: FileLayer[]; +}): Visualization => ({ + id: 'lnsChoropleth', + + visualizationTypes: [ + { + id: 'lnsChoropleth', + icon: Icon, + label: CHART_LABEL, + groupLabel: i18n.translate('xpack.maps.lens.groupLabel', { + defaultMessage: 'Map', + }), + sortPriority: 1, + showExperimentalBadge: true, + }, + ], + + getVisualizationTypeId() { + return 'lnsChoropleth'; + }, + + clearLayer(state) { + const newState = { ...state }; + delete newState.emsLayerId; + delete newState.emsField; + delete newState.regionAccessor; + delete newState.valueAccessor; + return newState; + }, + + getLayerIds(state) { + return [state.layerId]; + }, + + getDescription() { + return { + icon: Icon, + label: CHART_LABEL, + }; + }, + + getSuggestions(suggestionRequest: SuggestionRequest) { + return getSuggestions(suggestionRequest, emsFileLayers); + }, + + initialize(addNewLayer, state) { + return ( + state || { + layerId: addNewLayer(), + layerType: layerTypes.DATA, + } + ); + }, + + getConfiguration({ state }) { + return { + groups: [ + { + groupId: REGION_KEY_GROUP_ID, + groupLabel: i18n.translate('xpack.maps.lens.choroplethChart.regionKeyLabel', { + defaultMessage: 'Region key', + }), + layerId: state.layerId, + accessors: state.regionAccessor ? [{ columnId: state.regionAccessor }] : [], + supportsMoreColumns: !state.regionAccessor, + filterOperations: (op: OperationMetadata) => op.isBucketed && op.dataType === 'string', + enableDimensionEditor: true, + required: true, + dataTestSubj: 'lnsChoropleth_regionKeyDimensionPanel', + }, + { + groupId: METRIC_GROUP_ID, + groupLabel: i18n.translate('xpack.maps.lens.choroplethChart.metricValueLabel', { + defaultMessage: 'Metric', + }), + layerId: state.layerId, + accessors: state.valueAccessor ? [{ columnId: state.valueAccessor }] : [], + supportsMoreColumns: !state.valueAccessor, + filterOperations: (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number', + enableDimensionEditor: true, + required: true, + dataTestSubj: 'lnsChoropleth_valueDimensionPanel', + }, + ], + }; + }, + + getSupportedLayers() { + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.maps.lens.choroplethChart.addLayer', { + defaultMessage: 'Add visualization layer', + }), + }, + ]; + }, + + getLayerType(layerId, state) { + if (state?.layerId === layerId) { + return layerTypes.DATA; + } + }, + + toExpression: (state, datasourceLayers, attributes) => { + if (!state.regionAccessor || !state.valueAccessor) { + return null; + } + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_choropleth_chart', + arguments: { + title: [attributes?.title || ''], + layerId: [state.layerId], + emsField: state.emsField ? [state.emsField] : [], + emsLayerId: state.emsLayerId ? [state.emsLayerId] : [], + regionAccessor: [state.regionAccessor], + valueAccessor: [state.valueAccessor], + }, + }, + ], + }; + }, + + toPreviewExpression: (state, datasourceLayers) => { + return null; + }, + + setDimension({ columnId, groupId, prevState }) { + const update: Partial = {}; + if (groupId === REGION_KEY_GROUP_ID) { + update.regionAccessor = columnId; + } else if (groupId === METRIC_GROUP_ID) { + update.valueAccessor = columnId; + } + return { + ...prevState, + ...update, + }; + }, + + removeDimension({ prevState, layerId, columnId }) { + const update = { ...prevState }; + + if (prevState.regionAccessor === columnId) { + delete update.regionAccessor; + delete update.emsLayerId; + delete update.emsField; + } else if (prevState.valueAccessor === columnId) { + delete update.valueAccessor; + } + + return update; + }, + + renderDimensionEditor(domElement, props) { + if (props.groupId === REGION_KEY_GROUP_ID) { + render( + + + + + , + domElement + ); + } + }, + + getErrorMessages(state) { + return undefined; + }, +}); diff --git a/x-pack/plugins/maps/public/lens/index.ts b/x-pack/plugins/maps/public/lens/index.ts new file mode 100644 index 0000000000000..1af581ca196f0 --- /dev/null +++ b/x-pack/plugins/maps/public/lens/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { setupLensChoroplethChart } from './choropleth_chart'; diff --git a/x-pack/plugins/maps/public/lens/passive_map.tsx b/x-pack/plugins/maps/public/lens/passive_map.tsx new file mode 100644 index 0000000000000..3db464405b3a5 --- /dev/null +++ b/x-pack/plugins/maps/public/lens/passive_map.tsx @@ -0,0 +1,103 @@ +/* + * 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, RefObject } from 'react'; +import uuid from 'uuid/v4'; +import { EuiLoadingChart } from '@elastic/eui'; +import { EmbeddableFactory, ViewMode } from '../../../../../src/plugins/embeddable/public'; +import type { LayerDescriptor } from '../../common/descriptor_types'; +import { INITIAL_LOCATION } from '../../common'; +import { MapEmbeddable, MapEmbeddableInput, MapEmbeddableOutput } from '../embeddable'; +import { createBasemapLayerDescriptor } from '../classes/layers/create_basemap_layer_descriptor'; + +interface Props { + factory: EmbeddableFactory; + passiveLayer: LayerDescriptor; +} + +interface State { + mapEmbeddable: MapEmbeddable | null; +} + +/* + * PassiveMap compoment is a wrapper around a map embeddable where passive layer descriptor provides features + * and layer does not auto-fetch features based on changes to pan, zoom, filter, query, timeRange, and other state changes. + * To update features, update passiveLayer prop with new layer descriptor. + * Contrast with traditional map (active map), where layers independently auto-fetch features + * based on changes to pan, zoom, filter, query, timeRange, and other state changes + */ +export class PassiveMap extends Component { + private _isMounted = false; + private _prevPassiveLayer = this.props.passiveLayer; + private readonly _embeddableRef: RefObject = React.createRef(); + + state: State = { mapEmbeddable: null }; + + componentDidMount() { + this._isMounted = true; + this._setupEmbeddable(); + } + + componentWillUnmount() { + this._isMounted = false; + if (this.state.mapEmbeddable) { + this.state.mapEmbeddable.destroy(); + } + } + + componentDidUpdate() { + if (this.state.mapEmbeddable && this._prevPassiveLayer !== this.props.passiveLayer) { + this.state.mapEmbeddable.updateLayerById(this.props.passiveLayer); + this._prevPassiveLayer = this.props.passiveLayer; + } + } + + async _setupEmbeddable() { + const basemapLayerDescriptor = createBasemapLayerDescriptor(); + const intialLayers = basemapLayerDescriptor ? [basemapLayerDescriptor] : []; + const mapEmbeddable = await this.props.factory.create({ + id: uuid(), + attributes: { + title: '', + layerListJSON: JSON.stringify([...intialLayers, this.props.passiveLayer]), + }, + filters: [], + hidePanelTitles: true, + viewMode: ViewMode.VIEW, + isLayerTOCOpen: false, + hideFilterActions: true, + mapSettings: { + disableInteractive: false, + hideToolbarOverlay: false, + hideLayerControl: false, + hideViewControl: false, + initialLocation: INITIAL_LOCATION.AUTO_FIT_TO_BOUNDS, // this will startup based on data-extent + autoFitToDataBounds: true, // this will auto-fit when there are changes to the filter and/or query + }, + }); + + if (!mapEmbeddable) { + return; + } + + if (this._isMounted) { + this.setState({ mapEmbeddable: mapEmbeddable as MapEmbeddable }, () => { + if (this.state.mapEmbeddable && this._embeddableRef.current) { + this.state.mapEmbeddable.render(this._embeddableRef.current); + } + }); + } + } + + render() { + if (!this.state.mapEmbeddable) { + return ; + } + + return
; + } +} diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 69d973dd4427b..21c33cdcb500a 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -12,6 +12,7 @@ import type { NavigationPublicPluginStart } from 'src/plugins/navigation/public' import type { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import type { DashboardStart } from 'src/plugins/dashboard/public'; import type { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; +import type { FieldFormatsStart } from 'src/plugins/field_formats/public'; import type { AppMountParameters, CoreSetup, @@ -73,11 +74,15 @@ import { } from './legacy_visualizations'; import type { SecurityPluginStart } from '../../security/public'; import type { SpacesPluginStart } from '../../spaces/public'; +import type { LensPublicSetup } from '../../lens/public'; + +import { setupLensChoroplethChart } from './lens'; export interface MapsPluginSetupDependencies { expressions: ReturnType; inspector: InspectorSetupContract; home?: HomePublicPluginSetup; + lens: LensPublicSetup; visualizations: VisualizationsSetup; embeddable: EmbeddableSetup; share: SharePluginSetup; @@ -89,6 +94,7 @@ export interface MapsPluginStartDependencies { charts: ChartsPluginStart; data: DataPublicPluginStart; embeddable: EmbeddableStart; + fieldFormats: FieldFormatsStart; fileUpload: FileUploadPluginStart; inspector: InspectorStartContract; licensing: LicensingPluginStart; @@ -130,7 +136,10 @@ export class MapsPlugin this._initializerContext = initializerContext; } - public setup(core: CoreSetup, plugins: MapsPluginSetupDependencies): MapsSetupApi { + public setup( + core: CoreSetup, + plugins: MapsPluginSetupDependencies + ): MapsSetupApi { registerLicensedFeatures(plugins.licensing); const config = this._initializerContext.config.get(); @@ -174,6 +183,8 @@ export class MapsPlugin }, }); + setupLensChoroplethChart(core, plugins.expressions, plugins.lens); + // register wrapper around legacy tile_map and region_map visualizations plugins.expressions.registerFunction(createRegionMapFn); plugins.expressions.registerRenderer(regionMapRenderer); diff --git a/x-pack/plugins/maps/public/reducers/map/map.ts b/x-pack/plugins/maps/public/reducers/map/map.ts index c6b036032e181..c747f45f5f17a 100644 --- a/x-pack/plugins/maps/public/reducers/map/map.ts +++ b/x-pack/plugins/maps/public/reducers/map/map.ts @@ -22,6 +22,7 @@ import { MAP_READY, MAP_DESTROYED, SET_QUERY, + UPDATE_LAYER, UPDATE_LAYER_PROP, UPDATE_LAYER_STYLE, SET_LAYER_STYLE_META, @@ -53,6 +54,7 @@ import { getLayerIndex, removeTrackedLayerState, rollbackTrackedLayerState, + setLayer, trackCurrentLayerState, updateLayerInList, updateLayerSourceDescriptorProp, @@ -271,6 +273,11 @@ export function map(state: MapState = DEFAULT_MAP_STATE, action: Record id !== action.id)], }; + case UPDATE_LAYER: + return { + ...state, + layerList: setLayer(state.layerList, action.layer), + }; case ADD_WAITING_FOR_MAP_READY_LAYER: return { ...state, diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json index e2be2f3d66561..ed188c609c330 100644 --- a/x-pack/plugins/maps/tsconfig.json +++ b/x-pack/plugins/maps/tsconfig.json @@ -33,6 +33,7 @@ { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../features/tsconfig.json" }, + { "path": "../lens/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../file_upload/tsconfig.json" }, { "path": "../saved_objects_tagging/tsconfig.json" }, diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index f8c5ddc37a216..dbfacc7941794 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -96,6 +96,7 @@ export default function ({ loadTestFile, getService }) { loadTestFile(require.resolve('./visualize_create_menu')); loadTestFile(require.resolve('./discover')); loadTestFile(require.resolve('./geofile_wizard_auto_open')); + loadTestFile(require.resolve('./lens')); }); }); } diff --git a/x-pack/test/functional/apps/maps/lens/choropleth_chart.ts b/x-pack/test/functional/apps/maps/lens/choropleth_chart.ts new file mode 100644 index 0000000000000..daa490f8ef051 --- /dev/null +++ b/x-pack/test/functional/apps/maps/lens/choropleth_chart.ts @@ -0,0 +1,66 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'maps']); + const testSubjects = getService('testSubjects'); + const filterBar = getService('filterBar'); + + describe('choropleth chart', () => { + it('should allow creation of choropleth chart', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.switchToVisualization('lnsChoropleth', 'Region map'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsChoropleth_regionKeyDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.dest', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsChoropleth_valueDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.maps.openLegend(); + await PageObjects.maps.waitForLayersToLoad(); + + expect(await PageObjects.maps.getNumberOfLayers()).to.eql(2); + expect(await PageObjects.maps.doesLayerExist('World Countries by Average of bytes')).to.be( + true + ); + }); + + it('should create choropleth chart from suggestion', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.dragFieldToWorkspace('geo.dest'); + + // add filter to force data fetch to set activeData + await filterBar.addFilter('bytes', 'is between', '200', '10000'); + + await testSubjects.click('lnsSuggestion-worldCountriesByCountOfRecords > lnsSuggestion'); + + await PageObjects.maps.openLegend(); + await PageObjects.maps.waitForLayersToLoad(); + + expect(await PageObjects.maps.getNumberOfLayers()).to.eql(2); + expect(await PageObjects.maps.doesLayerExist('World Countries by Count of records')).to.be( + true + ); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/lens/index.ts b/x-pack/test/functional/apps/maps/lens/index.ts new file mode 100644 index 0000000000000..78086303166a1 --- /dev/null +++ b/x-pack/test/functional/apps/maps/lens/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('lens', function () { + loadTestFile(require.resolve('./choropleth_chart')); + }); +}