kibana/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx
Nathan Reese 14442b78de
[Maps] spatially filter by all geo fields (#100735)
* [Maps] spatial filter by all geo fields

* replace geoFields with geoFieldNames

* update mapSpatialFilter to be able to reconize multi field filters

* add check for geoFieldNames

* i18n fixes and fix GeometryFilterForm jest test

* tslint

* tslint

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
2021-06-02 06:17:23 -06:00

574 lines
17 KiB
TypeScript

/*
* 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 _ from 'lodash';
import React from 'react';
import { Provider } from 'react-redux';
import { render, unmountComponentAtNode } from 'react-dom';
import { Subscription } from 'rxjs';
import { Unsubscribe } from 'redux';
import {
Embeddable,
IContainer,
ReferenceOrValueEmbeddable,
VALUE_CLICK_TRIGGER,
} from '../../../../../src/plugins/embeddable/public';
import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public';
import {
ACTION_GLOBAL_APPLY_FILTER,
APPLY_FILTER_TRIGGER,
esFilters,
TimeRange,
Filter,
Query,
RefreshInterval,
} from '../../../../../src/plugins/data/public';
import { createExtentFilter } from '../../common/elasticsearch_util';
import {
replaceLayerList,
setMapSettings,
setQuery,
setRefreshConfig,
disableScrollZoom,
setReadOnly,
} from '../actions';
import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors';
import {
getInspectorAdapters,
setChartsPaletteServiceGetColor,
setEventHandlers,
EventHandlers,
} from '../reducers/non_serializable_instances';
import {
getGeoFieldNames,
getMapCenter,
getMapBuffer,
getMapExtent,
getMapReady,
getMapZoom,
getHiddenLayerIds,
getQueryableUniqueIndexPatternIds,
} from '../selectors/map_selectors';
import {
APP_ID,
getExistingMapPath,
MAP_SAVED_OBJECT_TYPE,
MAP_PATH,
RawValue,
} from '../../common/constants';
import { RenderToolTipContent } from '../classes/tooltips/tooltip_property';
import {
getUiActions,
getCoreI18n,
getHttp,
getChartsPaletteServiceGetColor,
getSearchService,
} from '../kibana_services';
import { LayerDescriptor, MapExtent } from '../../common/descriptor_types';
import { MapContainer } from '../connected_components/map_container';
import { SavedMap } from '../routes/map_page';
import { getIndexPatternsFromIds } from '../index_pattern_util';
import { getMapAttributeService } from '../map_attribute_service';
import { isUrlDrilldown, toValueClickDataFormat } from '../trigger_actions/trigger_utils';
import {
MapByValueInput,
MapByReferenceInput,
MapEmbeddableConfig,
MapEmbeddableInput,
MapEmbeddableOutput,
} from './types';
function getIsRestore(searchSessionId?: string) {
if (!searchSessionId) {
return false;
}
const searchSessionOptions = getSearchService().session.getSearchOptions(searchSessionId);
return searchSessionOptions ? searchSessionOptions.isRestore : false;
}
export class MapEmbeddable
extends Embeddable<MapEmbeddableInput, MapEmbeddableOutput>
implements ReferenceOrValueEmbeddable<MapByValueInput, MapByReferenceInput> {
type = MAP_SAVED_OBJECT_TYPE;
private _isActive: boolean;
private _savedMap: SavedMap;
private _renderTooltipContent?: RenderToolTipContent;
private _subscription: Subscription;
private _prevFilterByMapExtent: boolean;
private _prevIsRestore: boolean = false;
private _prevMapExtent?: MapExtent;
private _prevTimeRange?: TimeRange;
private _prevQuery?: Query;
private _prevRefreshConfig?: RefreshInterval;
private _prevFilters: Filter[] = [];
private _prevSyncColors?: boolean;
private _prevSearchSessionId?: string;
private _domNode?: HTMLElement;
private _unsubscribeFromStore?: Unsubscribe;
private _isInitialized = false;
private _controlledBy: string;
constructor(config: MapEmbeddableConfig, initialInput: MapEmbeddableInput, parent?: IContainer) {
super(
initialInput,
{
editApp: APP_ID,
editable: config.editable,
indexPatterns: [],
},
parent
);
this._isActive = true;
this._savedMap = new SavedMap({ mapEmbeddableInput: initialInput });
this._initializeSaveMap();
this._subscription = this.getUpdated$().subscribe(() => this.onUpdate());
this._controlledBy = `mapEmbeddablePanel${this.id}`;
this._prevFilterByMapExtent =
this.input.filterByMapExtent === undefined ? false : this.input.filterByMapExtent;
}
private async _initializeSaveMap() {
try {
await this._savedMap.whenReady();
} catch (e) {
this.onFatalError(e);
return;
}
this._initializeStore();
try {
await this._initializeOutput();
} catch (e) {
this.onFatalError(e);
return;
}
this._isInitialized = true;
if (this._domNode) {
this.render(this._domNode);
}
}
private _initializeStore() {
this._dispatchSetChartsPaletteServiceGetColor(this.input.syncColors);
const store = this._savedMap.getStore();
store.dispatch(setReadOnly(true));
store.dispatch(disableScrollZoom());
store.dispatch(
setMapSettings({
showTimesliderToggleButton: false,
})
);
this._dispatchSetQuery({
forceRefresh: false,
});
if (this.input.refreshConfig) {
this._dispatchSetRefreshConfig(this.input.refreshConfig);
}
this._unsubscribeFromStore = this._savedMap.getStore().subscribe(() => {
this._handleStoreChanges();
});
}
private async _initializeOutput() {
const savedMapTitle = this._savedMap.getAttributes()?.title
? this._savedMap.getAttributes().title
: '';
const input = this.getInput();
const title = input.hidePanelTitles ? '' : input.title || savedMapTitle;
const savedObjectId = (input as MapByReferenceInput).savedObjectId;
this.updateOutput({
...this.getOutput(),
defaultTitle: savedMapTitle,
title,
editPath: `/${MAP_PATH}/${savedObjectId}`,
editUrl: getHttp().basePath.prepend(getExistingMapPath(savedObjectId)),
indexPatterns: await this._getIndexPatterns(),
});
}
public inputIsRefType(
input: MapByValueInput | MapByReferenceInput
): input is MapByReferenceInput {
return getMapAttributeService().inputIsRefType(input);
}
public async getInputAsRefType(): Promise<MapByReferenceInput> {
const input = getMapAttributeService().getExplicitInputFromEmbeddable(this);
return getMapAttributeService().getInputAsRefType(input, {
showSaveModal: true,
saveModalTitle: this.getTitle(),
});
}
public async getInputAsValueType(): Promise<MapByValueInput> {
const input = getMapAttributeService().getExplicitInputFromEmbeddable(this);
return getMapAttributeService().getInputAsValueType(input);
}
public getDescription() {
return this._isInitialized ? this._savedMap.getAttributes().description : '';
}
public supportedTriggers(): string[] {
return [APPLY_FILTER_TRIGGER, VALUE_CLICK_TRIGGER];
}
setRenderTooltipContent = (renderTooltipContent: RenderToolTipContent) => {
this._renderTooltipContent = renderTooltipContent;
};
setEventHandlers = (eventHandlers: EventHandlers) => {
this._savedMap.getStore().dispatch(setEventHandlers(eventHandlers));
};
getInspectorAdapters() {
return getInspectorAdapters(this._savedMap.getStore().getState());
}
onUpdate() {
if (
this.input.filterByMapExtent !== undefined &&
this._prevFilterByMapExtent !== this.input.filterByMapExtent
) {
this._prevFilterByMapExtent = this.input.filterByMapExtent;
if (this.input.filterByMapExtent) {
this.setMapExtentFilter();
} else {
this.clearMapExtentFilter();
}
}
if (
!_.isEqual(this.input.timeRange, this._prevTimeRange) ||
!_.isEqual(this.input.query, this._prevQuery) ||
!esFilters.compareFilters(this._getFilters(), this._prevFilters) ||
this._getSearchSessionId() !== this._prevSearchSessionId
) {
this._dispatchSetQuery({
forceRefresh: false,
});
}
if (this.input.refreshConfig && !_.isEqual(this.input.refreshConfig, this._prevRefreshConfig)) {
this._dispatchSetRefreshConfig(this.input.refreshConfig);
}
if (this.input.syncColors !== this._prevSyncColors) {
this._dispatchSetChartsPaletteServiceGetColor(this.input.syncColors);
}
const isRestore = getIsRestore(this._getSearchSessionId());
if (isRestore !== this._prevIsRestore) {
this._prevIsRestore = isRestore;
this._savedMap.getStore().dispatch(
setMapSettings({
disableInteractive: isRestore,
hideToolbarOverlay: isRestore,
})
);
}
}
_getFilters() {
return this.input.filters
? this.input.filters.filter(
(filter) => !filter.meta.disabled && filter.meta.controlledBy !== this._controlledBy
)
: [];
}
_getSearchSessionId() {
// New search session id causes all layers from elasticsearch to refetch data.
// Dashboard provides a new search session id anytime filters change.
// Thus, filtering embeddable container by map extent causes a new search session id any time the map is moved.
// Disabling search session when filtering embeddable container by map extent.
// The use case for search sessions (restoring results because of slow responses) does not match the use case of
// filtering by map extent (rapid responses as users explore their map).
return this.input.filterByMapExtent ? undefined : this.input.searchSessionId;
}
_dispatchSetQuery({ forceRefresh }: { forceRefresh: boolean }) {
const filters = this._getFilters();
this._prevTimeRange = this.input.timeRange;
this._prevQuery = this.input.query;
this._prevFilters = filters;
this._prevSearchSessionId = this._getSearchSessionId();
this._savedMap.getStore().dispatch<any>(
setQuery({
filters,
query: this.input.query,
timeFilters: this.input.timeRange,
forceRefresh,
searchSessionId: this._getSearchSessionId(),
searchSessionMapBuffer: getIsRestore(this._getSearchSessionId())
? this.input.mapBuffer
: undefined,
})
);
}
_dispatchSetRefreshConfig(refreshConfig: RefreshInterval) {
this._prevRefreshConfig = refreshConfig;
this._savedMap.getStore().dispatch(
setRefreshConfig({
isPaused: refreshConfig.pause,
interval: refreshConfig.value,
})
);
}
async _dispatchSetChartsPaletteServiceGetColor(syncColors?: boolean) {
this._prevSyncColors = syncColors;
const chartsPaletteServiceGetColor = syncColors
? await getChartsPaletteServiceGetColor()
: null;
if (syncColors !== this._prevSyncColors) {
return;
}
this._savedMap
.getStore()
.dispatch(setChartsPaletteServiceGetColor(chartsPaletteServiceGetColor));
}
/**
*
* @param {HTMLElement} domNode
* @param {ContainerState} containerState
*/
render(domNode: HTMLElement) {
this._domNode = domNode;
if (!this._isInitialized) {
return;
}
const I18nContext = getCoreI18n().Context;
render(
<Provider store={this._savedMap.getStore()}>
<I18nContext>
<MapContainer
onSingleValueTrigger={this.onSingleValueTrigger}
addFilters={this.input.hideFilterActions ? null : this.addFilters}
getFilterActions={this.getFilterActions}
getActionContext={this.getActionContext}
renderTooltipContent={this._renderTooltipContent}
title={this.getTitle()}
description={this.getDescription()}
/>
</I18nContext>
</Provider>,
this._domNode
);
}
setLayerList(layerList: LayerDescriptor[]) {
this._savedMap.getStore().dispatch<any>(replaceLayerList(layerList));
this._getIndexPatterns().then((indexPatterns) => {
this.updateOutput({
...this.getOutput(),
indexPatterns,
});
});
}
private async _getIndexPatterns() {
const queryableIndexPatternIds = getQueryableUniqueIndexPatternIds(
this._savedMap.getStore().getState()
);
return await getIndexPatternsFromIds(queryableIndexPatternIds);
}
onSingleValueTrigger = (actionId: string, key: string, value: RawValue) => {
const action = getUiActions().getAction(actionId);
if (!action) {
throw new Error('Unable to apply action, could not locate action');
}
const executeContext = {
...this.getActionContext(),
data: {
data: toValueClickDataFormat(key, value),
},
};
action.execute(executeContext);
};
addFilters = async (filters: Filter[], actionId: string = ACTION_GLOBAL_APPLY_FILTER) => {
const executeContext = {
...this.getActionContext(),
filters,
};
const action = getUiActions().getAction(actionId);
if (!action) {
throw new Error('Unable to apply filter, could not locate action');
}
action.execute(executeContext);
};
getFilterActions = async () => {
const filterActions = await getUiActions().getTriggerCompatibleActions(APPLY_FILTER_TRIGGER, {
embeddable: this,
filters: [],
});
const valueClickActions = await getUiActions().getTriggerCompatibleActions(
VALUE_CLICK_TRIGGER,
{
embeddable: this,
data: {
// uiActions.getTriggerCompatibleActions validates action with provided context
// so if event.key and event.value are used in the URL template but can not be parsed from context
// then the action is filtered out.
// To prevent filtering out actions, provide dummy context when initially fetching actions.
data: toValueClickDataFormat('anyfield', 'anyvalue'),
},
}
);
return [...filterActions, ...valueClickActions.filter(isUrlDrilldown)];
};
getActionContext = () => {
const trigger = getUiActions().getTrigger(APPLY_FILTER_TRIGGER);
if (!trigger) {
throw new Error('Unable to get context, could not locate trigger');
}
return {
embeddable: this,
trigger,
} as ActionExecutionContext;
};
setMapExtentFilter() {
const state = this._savedMap.getStore().getState();
const mapExtent = getMapExtent(state);
const geoFieldNames = getGeoFieldNames(state);
const center = getMapCenter(state);
const zoom = getMapZoom(state);
if (center === undefined || mapExtent === undefined || geoFieldNames.length === 0) {
return;
}
this._prevMapExtent = mapExtent;
const mapExtentFilter = createExtentFilter(mapExtent, geoFieldNames);
mapExtentFilter.meta.controlledBy = this._controlledBy;
mapExtentFilter.meta.alias = i18n.translate('xpack.maps.embeddable.boundsFilterLabel', {
defaultMessage: 'Map bounds at center: {lat}, {lon}, zoom: {zoom}',
values: {
lat: center.lat,
lon: center.lon,
zoom,
},
});
const executeContext = {
...this.getActionContext(),
filters: [mapExtentFilter],
controlledBy: this._controlledBy,
};
const action = getUiActions().getAction(ACTION_GLOBAL_APPLY_FILTER);
if (!action) {
throw new Error('Unable to apply map extent filter, could not locate action');
}
action.execute(executeContext);
}
clearMapExtentFilter() {
this._prevMapExtent = undefined;
const executeContext = {
...this.getActionContext(),
filters: [],
controlledBy: this._controlledBy,
};
const action = getUiActions().getAction(ACTION_GLOBAL_APPLY_FILTER);
if (!action) {
throw new Error('Unable to apply map extent filter, could not locate action');
}
action.execute(executeContext);
}
destroy() {
super.destroy();
this._isActive = false;
if (this._unsubscribeFromStore) {
this._unsubscribeFromStore();
}
if (this._domNode) {
unmountComponentAtNode(this._domNode);
}
if (this._subscription) {
this._subscription.unsubscribe();
}
}
reload() {
this._dispatchSetQuery({
forceRefresh: true,
});
}
_handleStoreChanges() {
if (!this._isActive || !getMapReady(this._savedMap.getStore().getState())) {
return;
}
const mapExtent = getMapExtent(this._savedMap.getStore().getState());
if (this.input.filterByMapExtent && !_.isEqual(this._prevMapExtent, mapExtent)) {
this.setMapExtentFilter();
}
const center = getMapCenter(this._savedMap.getStore().getState());
const zoom = getMapZoom(this._savedMap.getStore().getState());
const mapCenter = this.input.mapCenter || undefined;
if (
!mapCenter ||
mapCenter.lat !== center.lat ||
mapCenter.lon !== center.lon ||
mapCenter.zoom !== zoom
) {
this.updateInput({
mapCenter: {
lat: center.lat,
lon: center.lon,
zoom,
},
mapBuffer: getMapBuffer(this._savedMap.getStore().getState()),
});
}
const isLayerTOCOpen = getIsLayerTOCOpen(this._savedMap.getStore().getState());
if (this.input.isLayerTOCOpen !== isLayerTOCOpen) {
this.updateInput({
isLayerTOCOpen,
});
}
const openTOCDetails = getOpenTOCDetails(this._savedMap.getStore().getState());
if (!_.isEqual(this.input.openTOCDetails, openTOCDetails)) {
this.updateInput({
openTOCDetails,
});
}
const hiddenLayerIds = getHiddenLayerIds(this._savedMap.getStore().getState());
if (!_.isEqual(this.input.hiddenLayers, hiddenLayerIds)) {
this.updateInput({
hiddenLayers: hiddenLayerIds,
});
}
}
}