React map embeddable (#178158)

Closes https://github.com/elastic/kibana/issues/174960 and
https://github.com/elastic/kibana/issues/179677

PR refactors Map embeddable from legacy Embeddable class to react
embeddable.

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Nick Peihl <nickpeihl@gmail.com>
This commit is contained in:
Nathan Reese 2024-06-13 14:09:13 -06:00 committed by GitHub
parent f016398f8b
commit 3d419ec027
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1834 additions and 1919 deletions

View file

@ -27,8 +27,8 @@ export const canLinkLegacyEmbeddable = async (embeddable: CommonLegacyEmbeddable
return false;
}
const { maps, visualize } = core.application.capabilities;
const canSave = embeddable.type === 'map' ? maps.save : visualize.save;
const { visualize } = core.application.capabilities;
const canSave = visualize.save;
const { isOfAggregateQueryType } = await import('@kbn/es-query');
const query = isFilterableEmbeddable(embeddable) && embeddable.getQuery();

View file

@ -6,29 +6,6 @@
*/
import { savedMap } from './saved_map';
import { getQueryFilters } from '../../../common/lib/build_embeddable_filters';
import { ExpressionValueFilter } from '../../../types';
const filterContext: ExpressionValueFilter = {
type: 'filter',
and: [
{
type: 'filter',
and: [],
value: 'filter-value',
column: 'filter-column',
filterType: 'exactly',
},
{
type: 'filter',
and: [],
column: 'time-column',
filterType: 'time',
from: '2019-06-04T04:00:00.000Z',
to: '2019-06-05T04:00:00.000Z',
},
],
};
describe('savedMap', () => {
const fn = savedMap().fn;
@ -43,13 +20,18 @@ describe('savedMap', () => {
it('accepts null context', () => {
const expression = fn(null, args, {} as any);
expect(expression.input.filters).toEqual([]);
});
it('accepts filter context', () => {
const expression = fn(filterContext, args, {} as any);
const embeddableFilters = getQueryFilters(filterContext.and);
expect(expression.input.filters).toEqual(embeddableFilters);
expect(expression.input).toEqual({
hiddenLayers: [],
hideFilterActions: true,
id: 'some-id',
isLayerTOCOpen: false,
mapCenter: undefined,
savedObjectId: 'some-id',
timeRange: {
from: 'now-15m',
to: 'now',
},
title: undefined,
});
});
});

View file

@ -6,9 +6,8 @@
*/
import { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import type { MapEmbeddableInput } from '@kbn/maps-plugin/public';
import type { MapSerializedState } from '@kbn/maps-plugin/public';
import { SavedObjectReference } from '@kbn/core/types';
import { getQueryFilters } from '../../../common/lib/build_embeddable_filters';
import { ExpressionValueFilter, MapCenter, TimeRange as TimeRangeArg } from '../../../types';
import {
EmbeddableTypes,
@ -30,7 +29,7 @@ const defaultTimeRange = {
to: 'now',
};
type Output = EmbeddableExpression<MapEmbeddableInput & { savedObjectId: string }>;
type Output = EmbeddableExpression<MapSerializedState & { id: string }>;
export function savedMap(): ExpressionFunctionDefinition<
'savedMap',
@ -72,30 +71,19 @@ export function savedMap(): ExpressionFunctionDefinition<
},
type: EmbeddableExpressionType,
fn: (input, args) => {
const filters = input ? input.and : [];
const center = args.center
? {
lat: args.center.lat,
lon: args.center.lon,
zoom: args.center.zoom,
}
: undefined;
return {
type: EmbeddableExpressionType,
input: {
id: args.id,
attributes: { title: '' },
savedObjectId: args.id,
filters: getQueryFilters(filters),
timeRange: args.timerange || defaultTimeRange,
refreshConfig: {
pause: false,
value: 0,
},
mapCenter: center,
mapCenter: args.center
? {
lat: args.center.lat,
lon: args.center.lon,
zoom: args.center.zoom,
}
: undefined,
hideFilterActions: true,
title: args.title === null ? undefined : args.title,
isLayerTOCOpen: false,

View file

@ -5,9 +5,9 @@
* 2.0.
*/
import { MapEmbeddableInput } from '@kbn/maps-plugin/public';
import { MapSerializedState } from '@kbn/maps-plugin/public';
export function toExpression(input: MapEmbeddableInput & { savedObjectId: string }): string {
export function toExpression(input: MapSerializedState & { id: string }): string {
const expressionParts = [] as string[];
expressionParts.push('savedMap');

View file

@ -13,7 +13,7 @@ test('Should return original state and empty references with by-reference embedd
type: 'map',
};
expect(extract!(mapByReferenceInput)).toEqual({
expect(extract(mapByReferenceInput)).toEqual({
state: mapByReferenceInput,
references: [],
});
@ -29,7 +29,7 @@ test('Should update state with refNames with by-value embeddable state', () => {
type: 'map',
};
expect(extract!(mapByValueInput)).toEqual({
expect(extract(mapByValueInput)).toEqual({
references: [
{
id: '90943e30-9a47-11e8-b64d-95841ca0b247',

View file

@ -10,7 +10,7 @@ import { MapEmbeddablePersistableState } from './types';
import type { MapAttributes } from '../content_management';
import { extractReferences } from '../migrations/references';
export const extract: EmbeddableRegistryDefinition['extract'] = (state) => {
export const extract: NonNullable<EmbeddableRegistryDefinition['extract']> = (state) => {
const typedState = state as MapEmbeddablePersistableState;
// by-reference embeddable

View file

@ -5,5 +5,6 @@
* 2.0.
*/
export type { MapEmbeddablePersistableState } from './types';
export { extract } from './extract';
export { inject } from './inject';

View file

@ -21,7 +21,7 @@ test('Should return original state with by-reference embeddable state', () => {
},
];
expect(inject!(mapByReferenceInput, refernces)).toEqual(mapByReferenceInput);
expect(inject(mapByReferenceInput, refernces)).toEqual(mapByReferenceInput);
});
test('Should inject refNames with by-value embeddable state', () => {
@ -41,7 +41,7 @@ test('Should inject refNames with by-value embeddable state', () => {
},
];
expect(inject!(mapByValueInput, refernces)).toEqual({
expect(inject(mapByValueInput, refernces)).toEqual({
id: '8d62c3f0-c61f-4c09-ac24-9b8ee4320e20',
attributes: {
layerListJSON: '[{"sourceDescriptor":{"indexPatternId":"changed_index_pattern_id"}}]',

View file

@ -10,7 +10,7 @@ import type { MapEmbeddablePersistableState } from './types';
import type { MapAttributes } from '../content_management';
import { extractReferences, injectReferences } from '../migrations/references';
export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => {
export const inject: NonNullable<EmbeddableRegistryDefinition['inject']> = (state, references) => {
const typedState = state as MapEmbeddablePersistableState;
// by-reference embeddable

View file

@ -33,6 +33,7 @@
"optionalPlugins": [
"cloud",
"customIntegrations",
"embeddableEnhanced",
"home",
"savedObjectsTagging",
"charts",

View file

@ -14,4 +14,4 @@
@import 'components/index';
@import 'classes/index';
@import 'animations';
@import 'embeddable/index';
@import 'react_embeddable/index';

View file

@ -19,7 +19,6 @@ import {
getLayerListRaw,
getMapColors,
getMapReady,
getMapSettings,
getSelectedLayerId,
} from '../selectors/map_selectors';
import { FLYOUT_STATE } from '../reducers/ui';
@ -44,12 +43,7 @@ import {
UPDATE_LAYER_STYLE,
UPDATE_SOURCE_PROP,
} from './map_action_constants';
import {
autoFitToBounds,
clearDataRequests,
syncDataForLayerId,
updateStyleMeta,
} from './data_request_actions';
import { clearDataRequests, syncDataForLayerId, updateStyleMeta } from './data_request_actions';
import {
Attribution,
JoinDescriptor,
@ -138,19 +132,10 @@ export function replaceLayerList(newLayerList: LayerDescriptor[]) {
};
}
export function updateLayerById(layerDescriptor: LayerDescriptor) {
return async (
dispatch: ThunkDispatch<MapStoreState, void, AnyAction>,
getState: () => MapStoreState
) => {
dispatch({
type: UPDATE_LAYER,
layer: layerDescriptor,
});
await dispatch(syncDataForLayerId(layerDescriptor.id, false));
if (getMapSettings(getState()).autoFitToDataBounds) {
dispatch(autoFitToBounds());
}
export function updateLayerDescriptor(layerDescriptor: LayerDescriptor) {
return {
type: UPDATE_LAYER,
layer: layerDescriptor,
};
}

View file

@ -9,7 +9,7 @@ import type { LayerDescriptor } from '../../common/descriptor_types';
import type { CreateLayerDescriptorParams } from '../classes/sources/es_search_source';
import type { SampleValuesConfig, EMSTermJoinConfig } from '../ems_autosuggest';
import type { Props as PassiveMapProps } from '../lens/passive_map';
import type { Props as MapProps } from '../embeddable/map_component';
import type { Props as MapProps } from '../react_embeddable/map_renderer';
export interface MapsStartApi {
createLayerDescriptors: {

View file

@ -42,6 +42,10 @@ export class InvalidLayer extends AbstractLayer {
};
}
isLayerLoading() {
return false;
}
hasErrors() {
return true;
}

View file

@ -1,10 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './map_embeddable';
export * from './types';
export * from './map_embeddable_factory';

View file

@ -1,38 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
HasParentApi,
HasType,
PublishesDataViews,
PublishesPanelTitle,
PublishesUnifiedSearch,
} from '@kbn/presentation-publishing';
import {
apiIsOfType,
apiPublishesUnifiedSearch,
apiPublishesPanelTitle,
} from '@kbn/presentation-publishing';
import type { ILayer } from '../classes/layers/layer';
export type MapApi = HasType<'map'> & {
getLayerList: () => ILayer[];
reload: () => void;
} & PublishesDataViews &
PublishesPanelTitle &
PublishesUnifiedSearch &
Partial<HasParentApi<unknown>>;
export const isMapApi = (api: unknown): api is MapApi => {
return Boolean(
api &&
apiIsOfType(api, 'map') &&
typeof (api as MapApi).getLayerList === 'function' &&
apiPublishesPanelTitle(api) &&
apiPublishesUnifiedSearch(api)
);
};

View file

@ -1,126 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Component, RefObject } from 'react';
import { first } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { LayerDescriptor, MapCenterAndZoom, MapSettings } from '../../common/descriptor_types';
import { MapEmbeddable } from './map_embeddable';
import { createBasemapLayerDescriptor } from '../classes/layers/create_basemap_layer_descriptor';
import { RenderToolTipContent } from '../classes/tooltips/tooltip_property';
import { MapApi } from './map_api';
export interface Props {
title?: string;
filters?: Filter[];
query?: Query;
timeRange?: TimeRange;
layerList: LayerDescriptor[];
mapSettings?: Partial<MapSettings>;
hideFilterActions?: boolean;
isLayerTOCOpen?: boolean;
mapCenter?: MapCenterAndZoom;
onInitialRenderComplete?: () => void;
getTooltipRenderer?: () => RenderToolTipContent;
onApiAvailable?: (api: MapApi) => void;
/*
* Set to false to exclude sharing attributes 'data-*'.
*/
isSharable?: boolean;
}
export class MapComponent extends Component<Props> {
private _prevLayerList: LayerDescriptor[];
private _mapEmbeddable: MapEmbeddable;
private readonly _embeddableRef: RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>();
constructor(props: Props) {
super(props);
this._prevLayerList = this.props.layerList;
this._mapEmbeddable = new MapEmbeddable(
{
editable: false,
},
{
id: uuidv4(),
attributes: {
title: this.props.title ?? '',
layerListJSON: JSON.stringify(this.getLayerList()),
},
hidePanelTitles: !Boolean(this.props.title),
viewMode: ViewMode.VIEW,
isLayerTOCOpen:
typeof this.props.isLayerTOCOpen === 'boolean' ? this.props.isLayerTOCOpen : false,
hideFilterActions:
typeof this.props.hideFilterActions === 'boolean' ? this.props.hideFilterActions : false,
mapCenter: this.props.mapCenter,
mapSettings: this.props.mapSettings ?? {},
}
);
this._mapEmbeddable.updateInput({
filters: this.props.filters,
query: this.props.query,
timeRange: this.props.timeRange,
});
if (this.props.getTooltipRenderer) {
this._mapEmbeddable.setRenderTooltipContent(this.props.getTooltipRenderer());
}
if (this.props.onApiAvailable) {
this.props.onApiAvailable(this._mapEmbeddable as MapApi);
}
if (this.props.onInitialRenderComplete) {
this._mapEmbeddable
.getOnRenderComplete$()
.pipe(first())
.subscribe(() => {
if (this.props.onInitialRenderComplete) {
this.props.onInitialRenderComplete();
}
});
}
if (this.props.isSharable !== undefined) {
this._mapEmbeddable.setIsSharable(this.props.isSharable);
}
}
componentDidMount() {
if (this._embeddableRef.current) {
this._mapEmbeddable.render(this._embeddableRef.current);
}
}
componentWillUnmount() {
this._mapEmbeddable.destroy();
}
componentDidUpdate() {
this._mapEmbeddable.updateInput({
filters: this.props.filters,
query: this.props.query,
timeRange: this.props.timeRange,
});
if (this._prevLayerList !== this.props.layerList) {
this._mapEmbeddable.setLayerList(this.getLayerList());
this._prevLayerList = this.props.layerList;
}
}
getLayerList(): LayerDescriptor[] {
const basemapLayer = createBasemapLayerDescriptor();
return basemapLayer ? [basemapLayer, ...this.props.layerList] : this.props.layerList;
}
render() {
return <div className="mapEmbeddableContainer" ref={this._embeddableRef} />;
}
}

View file

@ -1,304 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { v4 as uuidv4 } from 'uuid';
import { getControlledBy, MapEmbeddable } from './map_embeddable';
import { buildExistsFilter, disableFilter, pinFilter, toggleFilterNegated } from '@kbn/es-query';
import type { DataViewFieldBase, DataViewBase } from '@kbn/es-query';
import { MapEmbeddableConfig, MapEmbeddableInput } from './types';
import type { MapAttributes } from '../../common/content_management';
jest.mock('../kibana_services', () => {
return {
getExecutionContextService() {
return {
get: () => {
return {};
},
};
},
getHttp() {
return {
basePath: {
prepend: (url: string) => url,
},
};
},
getMapsCapabilities() {
return { save: true };
},
getSearchService() {
return {
session: {
getSearchOptions() {
return undefined;
},
},
};
},
getShowMapsInspectorAdapter() {
return false;
},
getTimeFilter() {
return {
getTime() {
return { from: 'now-7d', to: 'now' };
},
};
},
getEMSSettings() {
return {
isEMSUrlSet() {
return false;
},
};
},
};
});
jest.mock('../connected_components/map_container', () => {
return {
MapContainer: () => {
return <div>mockLayerTOC</div>;
},
};
});
jest.mock('../routes/map_page', () => {
class MockSavedMap {
// eslint-disable-next-line @typescript-eslint/no-var-requires
private _store = require('../reducers/store').createMapStore();
private _attributes: MapAttributes = {
title: 'myMap',
};
whenReady = async function () {};
getStore() {
return this._store;
}
getAttributes() {
return this._attributes;
}
getAutoFitToBounds() {
return true;
}
getSharingSavedObjectProps() {
return null;
}
}
return { SavedMap: MockSavedMap };
});
function untilInitialized(mapEmbeddable: MapEmbeddable): Promise<void> {
return new Promise((resolve) => {
// @ts-expect-error setInitializationFinished is protected but we are overriding it to know when embeddable is initialized
mapEmbeddable.setInitializationFinished = () => {
resolve();
};
});
}
function onNextTick(): Promise<void> {
// wait one tick to give observables time to fire
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe('shouldFetch$', () => {
test('should not fetch when search context does not change', async () => {
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
title: 'updated map title',
});
await onNextTick();
expect(fetchSpy).not.toHaveBeenCalled();
});
describe('on filters change', () => {
test('should fetch on filter change', async () => {
const existsFilter = buildExistsFilter(
{
name: 'myFieldName',
} as DataViewFieldBase,
{
id: 'myDataViewId',
} as DataViewBase
);
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
filters: [existsFilter],
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
filters: [toggleFilterNegated(existsFilter)],
});
await onNextTick();
expect(fetchSpy).toHaveBeenCalled();
});
test('should not fetch on disabled filter change', async () => {
const disabledFilter = disableFilter(
buildExistsFilter(
{
name: 'myFieldName',
} as DataViewFieldBase,
{
id: 'myDataViewId',
} as DataViewBase
)
);
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
filters: [disabledFilter],
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
filters: [toggleFilterNegated(disabledFilter)],
});
await onNextTick();
expect(fetchSpy).not.toHaveBeenCalled();
});
test('should not fetch when unpinned filter is pinned', async () => {
const unpinnedFilter = buildExistsFilter(
{
name: 'myFieldName',
} as DataViewFieldBase,
{
id: 'myDataViewId',
} as DataViewBase
);
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
filters: [unpinnedFilter],
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
filters: [pinFilter(unpinnedFilter)],
});
await onNextTick();
expect(fetchSpy).not.toHaveBeenCalled();
});
test('should not fetch on filter controlled by map embeddable change', async () => {
const embeddableId = 'map1';
const filter = buildExistsFilter(
{
name: 'myFieldName',
} as DataViewFieldBase,
{
id: 'myDataViewId',
} as DataViewBase
);
const controlledByFilter = {
...filter,
meta: {
...filter.meta,
controlledBy: getControlledBy(embeddableId),
},
};
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: embeddableId,
filters: [controlledByFilter],
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
filters: [toggleFilterNegated(controlledByFilter)],
});
await onNextTick();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('on searchSessionId change', () => {
test('should fetch when filterByMapExtent is false', async () => {
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
filterByMapExtent: false,
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
searchSessionId: uuidv4(),
});
await onNextTick();
expect(fetchSpy).toHaveBeenCalled();
});
test('should not fetch when filterByMapExtent is true', async () => {
const mapEmbeddable = new MapEmbeddable(
{} as unknown as MapEmbeddableConfig,
{
id: 'map1',
filterByMapExtent: true,
} as unknown as MapEmbeddableInput
);
await untilInitialized(mapEmbeddable);
const fetchSpy = jest.spyOn(mapEmbeddable, '_dispatchSetQuery');
mapEmbeddable.updateInput({
searchSessionId: uuidv4(),
});
await onNextTick();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
});

View file

@ -1,878 +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
* 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 fastIsEqual from 'fast-deep-equal';
import { render, unmountComponentAtNode } from 'react-dom';
import { Subscription } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
filter as filterOperator,
map,
skip,
startWith,
} from 'rxjs';
import { Unsubscribe } from 'redux';
import type { PaletteRegistry } from '@kbn/coloring';
import type { KibanaExecutionContext } from '@kbn/core/public';
import { EuiEmptyPrompt } from '@elastic/eui';
import { Query, type Filter } from '@kbn/es-query';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import {
Embeddable,
IContainer,
ReferenceOrValueEmbeddable,
genericEmbeddableInputIsEqual,
VALUE_CLICK_TRIGGER,
omitGenericEmbeddableInput,
FilterableEmbeddable,
shouldFetch$,
} from '@kbn/embeddable-plugin/public';
import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public';
import { ACTION_GLOBAL_APPLY_FILTER } from '@kbn/unified-search-plugin/public';
import { createExtentFilter } from '../../common/elasticsearch_util';
import {
replaceLayerList,
setMapSettings,
setQuery,
setReadOnly,
updateLayerById,
setGotoWithCenter,
setEmbeddableSearchContext,
setExecutionContext,
} from '../actions';
import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors';
import {
getInspectorAdapters,
setChartsPaletteServiceGetColor,
setEventHandlers,
setOnMapMove,
EventHandlers,
} from '../reducers/non_serializable_instances';
import {
isMapLoading,
getGeoFieldNames,
getEmbeddableSearchContext,
getLayerList,
getGoto,
getMapCenter,
getMapBuffer,
getMapExtent,
getMapReady,
getMapSettings,
getMapZoom,
getHiddenLayerIds,
getQueryableUniqueIndexPatternIds,
} from '../selectors/map_selectors';
import {
APP_ID,
getEditPath,
getFullPath,
MAP_EMBEDDABLE_NAME,
MAP_SAVED_OBJECT_TYPE,
RawValue,
RENDER_TIMEOUT,
} from '../../common/constants';
import { RenderToolTipContent } from '../classes/tooltips/tooltip_property';
import {
getAnalytics,
getCharts,
getCoreI18n,
getCoreOverlays,
getExecutionContextService,
getHttp,
getSearchService,
getSpacesApi,
getTheme,
getUiActions,
} from '../kibana_services';
import { LayerDescriptor, MapExtent } from '../../common/descriptor_types';
import { extractReferences } from '../../common/migrations/references';
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 { waitUntilTimeLayersLoad$ } from '../routes/map_page/map_app/wait_until_time_layers_load';
import { mapEmbeddablesSingleton } from './map_embeddables_singleton';
import { getGeoFieldsLabel } from './get_geo_fields_label';
import { checkForDuplicateTitle, getMapClient } from '../content_management';
import {
MapByValueInput,
MapByReferenceInput,
MapEmbeddableConfig,
MapEmbeddableInput,
MapEmbeddableOutput,
} from './types';
async function getChartsPaletteServiceGetColor(): Promise<((value: string) => string) | null> {
const chartsService = getCharts();
const paletteRegistry: PaletteRegistry | null = chartsService
? await chartsService.palettes.getPalettes()
: null;
if (!paletteRegistry) {
return null;
}
const paletteDefinition = paletteRegistry.get('default');
const chartConfiguration = { syncColors: true };
return (value: string) => {
const series = [{ name: value, rankAtDepth: 0, totalSeriesAtDepth: 1 }];
const color = paletteDefinition.getCategoricalColor(series, chartConfiguration);
return color ? color : '#3d3d3d';
};
}
function getIsRestore(searchSessionId?: string) {
if (!searchSessionId) {
return false;
}
const searchSessionOptions = getSearchService().session.getSearchOptions(searchSessionId);
return searchSessionOptions ? searchSessionOptions.isRestore : false;
}
export function getControlledBy(id: string) {
return `mapEmbeddablePanel${id}`;
}
export class MapEmbeddable
extends Embeddable<MapEmbeddableInput, MapEmbeddableOutput>
implements ReferenceOrValueEmbeddable<MapByValueInput, MapByReferenceInput>, FilterableEmbeddable
{
type = MAP_SAVED_OBJECT_TYPE;
deferEmbeddableLoad = true;
private _isActive: boolean;
private _savedMap: SavedMap;
private _renderTooltipContent?: RenderToolTipContent;
private _subscriptions: Subscription[] = [];
private _prevIsRestore: boolean = false;
private _prevMapExtent?: MapExtent;
private _prevSyncColors?: boolean;
private _domNode?: HTMLElement;
private _unsubscribeFromStore?: Unsubscribe;
private _isInitialized = false;
private _controlledBy: string;
private _isSharable = true;
private readonly _onRenderComplete$;
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._subscriptions.push(this.getUpdated$().subscribe(() => this.onUpdate()));
this._controlledBy = getControlledBy(this.id);
this._onRenderComplete$ = this.getOutput$().pipe(
// wrapping distinctUntilChanged with startWith and skip to prime distinctUntilChanged with an initial value.
startWith(this.getOutput()),
distinctUntilChanged((a, b) => a.loading === b.loading),
skip(1),
debounceTime(RENDER_TIMEOUT),
filterOperator((output) => !output.loading),
map(() => {
// Observable notifies subscriber when rendering is complete
// Return void to not expose internal implemenation details of observabale
return;
})
);
}
public getOnRenderComplete$() {
return this._onRenderComplete$;
}
public reportsEmbeddableLoad() {
return true;
}
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._savedMap.getStore().dispatch(setExecutionContext(this.getExecutionContext()));
// deferred loading of this embeddable is complete
this.setInitializationFinished();
this._isInitialized = true;
if (this._domNode) {
this.render(this._domNode);
}
}
private getExecutionContext() {
const parentContext = getExecutionContextService().get();
const mapContext: KibanaExecutionContext = {
type: APP_ID,
name: APP_ID,
id: this.id,
url: this.output.editPath,
};
return parentContext
? {
...parentContext,
child: mapContext,
}
: mapContext;
}
private _initializeStore() {
this._dispatchSetChartsPaletteServiceGetColor(this.input.syncColors);
const store = this._savedMap.getStore();
store.dispatch(setReadOnly(true));
store.dispatch(
setMapSettings({
keydownScrollZoom: true,
showTimesliderToggleButton: false,
})
);
// Passing callback into redux store instead of regular pattern of getting redux state changes for performance reasons
store.dispatch(setOnMapMove(this._propogateMapMovement));
this._dispatchSetQuery({ forceRefresh: false });
this._subscriptions.push(
shouldFetch$<MapEmbeddableInput>(this.getUpdated$(), () => {
return {
...this.getInput(),
filters: this._getInputFilters(),
searchSessionId: this._getSearchSessionId(),
};
}).subscribe(() => {
this._dispatchSetQuery({
forceRefresh: false,
});
})
);
const mapStateJSON = this._savedMap.getAttributes().mapStateJSON;
if (mapStateJSON) {
try {
const mapState = JSON.parse(mapStateJSON);
store.dispatch(
setEmbeddableSearchContext({
filters: mapState.filters ? mapState.filters : [],
query: mapState.query,
})
);
} catch (e) {
// ignore malformed mapStateJSON, not a critical error for viewing map - map will just use defaults
}
}
this._unsubscribeFromStore = store.subscribe(() => {
this._handleStoreChanges();
});
}
private async _initializeOutput() {
const { title: savedMapTitle, description: savedMapDescription } =
this._savedMap.getAttributes();
const input = this.getInput();
const title = input.hidePanelTitles ? '' : input.title ?? savedMapTitle;
const savedObjectId = 'savedObjectId' in input ? input.savedObjectId : undefined;
this.updateOutput({
defaultTitle: savedMapTitle,
defaultDescription: savedMapDescription,
title,
editPath: getEditPath(savedObjectId),
editUrl: getHttp().basePath.prepend(getFullPath(savedObjectId)),
indexPatterns: await this._getIndexPatterns(),
});
}
public inputIsRefType(
input: MapByValueInput | MapByReferenceInput
): input is MapByReferenceInput {
return getMapAttributeService().inputIsRefType(input);
}
public async getInputAsRefType(): Promise<MapByReferenceInput> {
return getMapAttributeService().getInputAsRefType(this.getExplicitInput(), {
showSaveModal: true,
saveModalTitle: this.getTitle(),
});
}
public async getExplicitInputIsEqual(
lastExplicitInput: Partial<MapByValueInput | MapByReferenceInput>
): Promise<boolean> {
const currentExplicitInput = this.getExplicitInput();
if (!genericEmbeddableInputIsEqual(lastExplicitInput, currentExplicitInput)) return false;
// generic embeddable input is equal, now we compare map specific input elements, ignoring 'mapBuffer'.
const lastMapInput = omitGenericEmbeddableInput(_.omit(lastExplicitInput, 'mapBuffer'));
const currentMapInput = omitGenericEmbeddableInput(_.omit(currentExplicitInput, 'mapBuffer'));
return fastIsEqual(lastMapInput, currentMapInput);
}
public async getInputAsValueType(): Promise<MapByValueInput> {
return getMapAttributeService().getInputAsValueType(this.getExplicitInput());
}
public getLayerList() {
return getLayerList(this._savedMap.getStore().getState());
}
public getFilters() {
const embeddableSearchContext = getEmbeddableSearchContext(
this._savedMap.getStore().getState()
);
return embeddableSearchContext ? embeddableSearchContext.filters : [];
}
public getQuery(): Query | undefined {
const embeddableSearchContext = getEmbeddableSearchContext(
this._savedMap.getStore().getState()
);
return embeddableSearchContext?.query;
}
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));
};
/*
* Set to false to exclude sharing attributes 'data-*'.
*/
public setIsSharable(isSharable: boolean): void {
this._isSharable = isSharable;
}
getInspectorAdapters() {
return getInspectorAdapters(this._savedMap.getStore().getState());
}
onUpdate() {
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,
})
);
}
}
_getIsMovementSynchronized = () => {
return this.input.isMovementSynchronized === undefined
? true
: this.input.isMovementSynchronized;
};
_getIsFilterByMapExtent = () => {
return this.input.filterByMapExtent === undefined ? false : this.input.filterByMapExtent;
};
_gotoSynchronizedLocation() {
const syncedLocation = mapEmbeddablesSingleton.getLocation();
if (syncedLocation) {
// set map to synchronized view
this._mapSyncHandler(syncedLocation.lat, syncedLocation.lon, syncedLocation.zoom);
return;
}
if (!getMapReady(this._savedMap.getStore().getState())) {
// Initialize synchronized view to map's goto
// Use goto because un-rendered map will not have accurate mapCenter and mapZoom.
const goto = getGoto(this._savedMap.getStore().getState());
if (goto && goto.center) {
mapEmbeddablesSingleton.setLocation(
this.input.id,
goto.center.lat,
goto.center.lon,
goto.center.zoom
);
return;
}
}
// Initialize synchronized view to map's view
const center = getMapCenter(this._savedMap.getStore().getState());
const zoom = getMapZoom(this._savedMap.getStore().getState());
mapEmbeddablesSingleton.setLocation(this.input.id, center.lat, center.lon, zoom);
}
_propogateMapMovement = (lat: number, lon: number, zoom: number) => {
if (this._getIsMovementSynchronized()) {
mapEmbeddablesSingleton.setLocation(this.input.id, lat, lon, zoom);
}
};
_getInputFilters() {
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 }) {
this._savedMap.getStore().dispatch<any>(
setQuery({
filters: this._getInputFilters(),
query: this.input.query,
timeFilters: this.input.timeRange,
timeslice: this.input.timeslice
? { from: this.input.timeslice[0], to: this.input.timeslice[1] }
: undefined,
clearTimeslice: this.input.timeslice === undefined,
forceRefresh,
searchSessionId: this._getSearchSessionId(),
searchSessionMapBuffer: getIsRestore(this._getSearchSessionId())
? this.input.mapBuffer
: undefined,
})
);
}
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;
}
mapEmbeddablesSingleton.register(this.input.id, {
getTitle: () => {
const output = this.getOutput();
if (output.title) {
return output.title;
}
if (output.defaultTitle) {
return output.defaultTitle;
}
return this.input.id;
},
onLocationChange: this._mapSyncHandler,
getIsMovementSynchronized: this._getIsMovementSynchronized,
setIsMovementSynchronized: (isMovementSynchronized: boolean) => {
this.updateInput({ isMovementSynchronized });
if (isMovementSynchronized) {
this._gotoSynchronizedLocation();
} else if (!isMovementSynchronized && this._savedMap.getAutoFitToBounds()) {
// restore autoFitToBounds when isMovementSynchronized disabled
this._savedMap.getStore().dispatch(setMapSettings({ autoFitToDataBounds: true }));
}
},
getIsFilterByMapExtent: this._getIsFilterByMapExtent,
setIsFilterByMapExtent: (isFilterByMapExtent: boolean) => {
this.updateInput({ filterByMapExtent: isFilterByMapExtent });
if (isFilterByMapExtent) {
this._setMapExtentFilter();
} else {
this._clearMapExtentFilter();
}
},
getGeoFieldNames: () => {
return getGeoFieldNames(this._savedMap.getStore().getState());
},
});
if (this._getIsMovementSynchronized()) {
this._gotoSynchronizedLocation();
}
const sharingSavedObjectProps = this._savedMap.getSharingSavedObjectProps();
const spaces = getSpacesApi();
const content =
sharingSavedObjectProps && spaces && sharingSavedObjectProps?.outcome === 'conflict' ? (
<div className="mapEmbeddedError">
<EuiEmptyPrompt
iconType="warning"
iconColor="danger"
data-test-subj="embeddable-maps-failure"
body={spaces.ui.components.getEmbeddableLegacyUrlConflict({
targetType: MAP_SAVED_OBJECT_TYPE,
sourceId: sharingSavedObjectProps.sourceId!,
})}
/>
</div>
) : (
<MapContainer
onSingleValueTrigger={this.onSingleValueTrigger}
addFilters={
this.input.hideFilterActions || this.input.disableTriggers ? null : this.addFilters
}
getFilterActions={this.getFilterActions}
getActionContext={this.getActionContext}
renderTooltipContent={this._renderTooltipContent}
title={this.getTitle()}
description={this.getDescription()}
waitUntilTimeLayersLoad$={waitUntilTimeLayersLoad$(this._savedMap.getStore())}
isSharable={this._isSharable}
/>
);
render(
<KibanaRenderContextProvider
analytics={getAnalytics()}
i18n={getCoreI18n()}
theme={getTheme()}
>
<Provider store={this._savedMap.getStore()}>{content}</Provider>
</KibanaRenderContextProvider>,
this._domNode
);
}
setLayerList(layerList: LayerDescriptor[]) {
this._savedMap.getStore().dispatch<any>(replaceLayerList(layerList));
this._getIndexPatterns().then((indexPatterns) => {
this.updateOutput({
indexPatterns,
});
});
}
updateLayerById(layerDescriptor: LayerDescriptor) {
this._savedMap.getStore().dispatch<any>(updateLayerById(layerDescriptor));
}
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;
};
// remove legacy library tranform methods
linkToLibrary = undefined;
unlinkFromLibrary = undefined;
// add implemenation for library transform methods
checkForDuplicateTitle = async (
newTitle: string,
isTitleDuplicateConfirmed: boolean,
onTitleDuplicate: () => void
) => {
await checkForDuplicateTitle(
{
title: newTitle,
copyOnSave: false,
lastSavedTitle: '',
isTitleDuplicateConfirmed,
getDisplayName: () => MAP_EMBEDDABLE_NAME,
onTitleDuplicate,
},
{
overlays: getCoreOverlays(),
}
);
};
saveToLibrary = async (title: string) => {
const { attributes, references } = extractReferences({
attributes: this._savedMap.getAttributes(),
});
const {
item: { id: savedObjectId },
} = await getMapClient().create({
data: {
...attributes,
title,
},
options: { references },
});
return savedObjectId;
};
getByReferenceState = (libraryId: string) => {
return {
..._.omit(this.getExplicitInput(), 'attributes'),
savedObjectId: libraryId,
};
};
getByValueState = () => {
return {
..._.omit(this.getExplicitInput(), 'savedObjectId'),
attributes: this._savedMap.getAttributes(),
};
};
// Timing bug for dashboard with multiple maps with synchronized movement and filter by map extent enabled
// When moving map with filterByMapExtent:false, previous map extent filter(s) does not get removed
// Cuased by syncDashboardContainerInput applyContainerChangesToState.
// 1) _setMapExtentFilter executes ACTION_GLOBAL_APPLY_FILTER action,
// removing previous map extent filter and adding new map extent filter
// 2) applyContainerChangesToState then re-adds stale input.filters (which contains previous map extent filter)
// Add debounce to fix timing issue.
// 1) applyContainerChangesToState now runs first and does its thing
// 2) _setMapExtentFilter executes ACTION_GLOBAL_APPLY_FILTER action,
// removing previous map extent filter and adding new map extent filter
_setMapExtentFilter = _.debounce(() => {
const mapExtent = getMapExtent(this._savedMap.getStore().getState());
const geoFieldNames = mapEmbeddablesSingleton.getGeoFieldNames();
if (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: '{geoFieldsLabel} within map bounds',
values: { geoFieldsLabel: getGeoFieldsLabel(geoFieldNames) },
});
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);
}, 100);
_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();
mapEmbeddablesSingleton.unregister(this.input.id);
this._isActive = false;
if (this._unsubscribeFromStore) {
this._unsubscribeFromStore();
}
if (this._domNode) {
unmountComponentAtNode(this._domNode);
}
this._subscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
}
reload() {
this._dispatchSetQuery({
forceRefresh: true,
});
}
_mapSyncHandler = (lat: number, lon: number, zoom: number) => {
// auto fit to bounds is not compatable with map synchronization
// auto fit to bounds may cause map location to never stablize and bound back and forth between bounds on different maps
if (getMapSettings(this._savedMap.getStore().getState()).autoFitToDataBounds) {
this._savedMap.getStore().dispatch(setMapSettings({ autoFitToDataBounds: false }));
}
this._savedMap.getStore().dispatch(setGotoWithCenter({ lat, lon, zoom }));
};
_handleStoreChanges() {
if (!this._isActive || !getMapReady(this._savedMap.getStore().getState())) {
return;
}
const mapExtent = getMapExtent(this._savedMap.getStore().getState());
if (this._getIsFilterByMapExtent() && !_.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,
});
}
const isLoading = isMapLoading(this._savedMap.getStore().getState());
if (this.getOutput().loading !== isLoading) {
/**
* Maps emit rendered when the data is loaded, as we don't have feedback from the maps rendering library atm.
* This means that the DASHBOARD_LOADED_EVENT event might be fired while a map is still rendering in some cases.
* For more details please contact the maps team.
*/
this.updateOutput({
loading: isLoading,
rendered: !isLoading,
// do not surface layer errors as output.error
// output.error blocks entire embeddable display and prevents map from displaying
// layer errors are better surfaced in legend while still keeping the map usable
});
}
}
}

View file

@ -1,73 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { first } from 'rxjs';
import { i18n } from '@kbn/i18n';
import { EmbeddableFactoryDefinition, IContainer } from '@kbn/embeddable-plugin/public';
import { MAP_SAVED_OBJECT_TYPE, APP_ICON, MAP_EMBEDDABLE_NAME } from '../../common/constants';
import { extract, inject } from '../../common/embeddable';
import { MapByReferenceInput, MapEmbeddableInput } from './types';
import { getApplication, getMapsCapabilities, getUsageCollection } from '../kibana_services';
export class MapEmbeddableFactory implements EmbeddableFactoryDefinition {
type = MAP_SAVED_OBJECT_TYPE;
savedObjectMetaData = {
name: i18n.translate('xpack.maps.mapSavedObjectLabel', {
defaultMessage: 'Map',
}),
type: MAP_SAVED_OBJECT_TYPE,
getIconForSavedObject: () => APP_ICON,
};
async isEditable() {
return getMapsCapabilities().save as boolean;
}
// Not supported yet for maps types.
canCreateNew() {
return false;
}
getDisplayName() {
return MAP_EMBEDDABLE_NAME;
}
createFromSavedObject = async (
savedObjectId: string,
input: MapEmbeddableInput,
parent?: IContainer
) => {
if (!(input as MapByReferenceInput).savedObjectId) {
(input as MapByReferenceInput).savedObjectId = savedObjectId;
}
return this.create(input, parent);
};
create = async (input: MapEmbeddableInput, parent?: IContainer) => {
const { MapEmbeddable } = await import('./map_embeddable');
const usageCollection = getUsageCollection();
if (usageCollection) {
// currentAppId$ is a BehaviorSubject exposed as an observable so subscription gets last value upon subscribe
getApplication()
.currentAppId$.pipe(first())
.subscribe((appId) => {
if (appId) usageCollection.reportUiCounter('map', 'loaded', `open_maps_vis_${appId}`);
});
}
return new MapEmbeddable(
{
editable: await this.isEditable(),
},
input,
parent
);
};
inject = inject;
extract = extract;
}

View file

@ -1,53 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Observable } from 'rxjs';
import type { DataView } from '@kbn/data-plugin/common';
import {
Embeddable,
EmbeddableInput,
EmbeddableOutput,
SavedObjectEmbeddableInput,
} from '@kbn/embeddable-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { MapCenterAndZoom, MapExtent, MapSettings } from '../../common/descriptor_types';
import type { MapAttributes } from '../../common/content_management';
export interface MapEmbeddableConfig {
editable: boolean;
}
interface MapEmbeddableState {
isLayerTOCOpen?: boolean;
openTOCDetails?: string[];
mapCenter?: MapCenterAndZoom;
mapBuffer?: MapExtent;
mapSettings?: Partial<MapSettings>;
hiddenLayers?: string[];
hideFilterActions?: boolean;
filters?: Filter[];
query?: Query;
timeRange?: TimeRange;
timeslice?: [number, number];
filterByMapExtent?: boolean;
isMovementSynchronized?: boolean;
}
export type MapByValueInput = {
attributes: MapAttributes;
} & EmbeddableInput &
MapEmbeddableState;
export type MapByReferenceInput = SavedObjectEmbeddableInput & MapEmbeddableState;
export type MapEmbeddableInput = MapByValueInput | MapByReferenceInput;
export type MapEmbeddableOutput = EmbeddableOutput & {
indexPatterns: DataView[];
};
export type MapEmbeddableType = Embeddable<MapEmbeddableInput, MapEmbeddableOutput> & {
getOnRenderComplete$(): Observable<void>;
setIsSharable(isSharable: boolean): void;
};

View file

@ -28,8 +28,7 @@ export type {
export type { MapsSetupApi, MapsStartApi } from './api';
export type { CreateLayerDescriptorParams } from './classes/sources/es_search_source/create_layer_descriptor';
export type { MapEmbeddable, MapEmbeddableInput, MapEmbeddableOutput } from './embeddable';
export { type MapApi, isMapApi } from './embeddable/map_api';
export { type MapApi, type MapSerializedState, isMapApi } from './react_embeddable/types';
export type { EMSTermJoinConfig, SampleValuesConfig } from './ems_autosuggest';

View file

@ -8,9 +8,23 @@
import type { CoreStart } from '@kbn/core/public';
import type { EMSSettings } from '@kbn/maps-ems-plugin/common/ems_settings';
import { MapsEmsPluginPublicStart } from '@kbn/maps-ems-plugin/public';
import { BehaviorSubject } from 'rxjs';
import type { MapsConfigType } from '../config';
import type { MapsPluginStartDependencies } from './plugin';
const servicesReady$ = new BehaviorSubject(false);
export const untilPluginStartServicesReady = () => {
if (servicesReady$.value) return Promise.resolve();
return new Promise<void>((resolve) => {
const subscription = servicesReady$.subscribe((isInitialized) => {
if (isInitialized) {
subscription.unsubscribe();
resolve();
}
});
});
};
let isDarkMode = false;
let coreStart: CoreStart;
let pluginsStart: MapsPluginStartDependencies;
@ -25,6 +39,8 @@ export function setStartServices(core: CoreStart, plugins: MapsPluginStartDepend
core.theme.theme$.subscribe(({ darkMode }) => {
isDarkMode = darkMode;
});
servicesReady$.next(true);
}
let isCloudEnabled = false;
@ -84,6 +100,7 @@ export const isScreenshotMode = () => {
return pluginsStart.screenshotMode ? pluginsStart.screenshotMode.isScreenshotMode() : false;
};
export const getServerless = () => pluginsStart.serverless;
export const getEmbeddableEnhanced = () => pluginsStart.embeddableEnhanced;
// xpack.maps.* kibana.yml settings from this plugin
let mapAppConfig: MapsConfigType;

View file

@ -9,8 +9,10 @@ import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import type { ExpressionRenderDefinition } from '@kbn/expressions-plugin/common';
import { dynamic } from '@kbn/shared-ux-utility';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { RegionMapVisRenderValue } from './region_map_fn';
import { REGION_MAP_RENDER } from './types';
import { getAnalytics, getCoreI18n, getTheme } from '../../kibana_services';
const Component = dynamic(async () => {
const { RegionMapVisualization } = await import('./region_map_visualization');
@ -37,6 +39,15 @@ export const regionMapRenderer = {
visConfig,
};
render(<Component {...props} />, domNode);
render(
<KibanaRenderContextProvider
analytics={getAnalytics()}
i18n={getCoreI18n()}
theme={getTheme()}
>
<Component {...props} />
</KibanaRenderContextProvider>,
domNode
);
},
} as ExpressionRenderDefinition<RegionMapVisRenderValue>;

View file

@ -6,10 +6,12 @@
*/
import React, { useMemo } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import { first } from 'rxjs';
import type { Filter } from '@kbn/es-query';
import type { Query, TimeRange } from '@kbn/es-query';
import { RegionMapVisConfig } from './types';
import { MapComponent } from '../../embeddable/map_component';
import { MapRenderer } from '../../react_embeddable/map_renderer';
import { createRegionMapLayerDescriptor } from '../../classes/layers/create_region_map_layer_descriptor';
interface Props {
@ -21,6 +23,7 @@ interface Props {
}
export function RegionMapVisualization(props: Props) {
const isMounted = useMountedState();
const initialMapCenter = useMemo(() => {
return {
lat: props.visConfig.mapCenter[0],
@ -37,7 +40,7 @@ export function RegionMapVisualization(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<MapComponent
<MapRenderer
title={props.visConfig.layerDescriptorParams.label}
filters={props.filters}
query={props.query}
@ -45,7 +48,13 @@ export function RegionMapVisualization(props: Props) {
mapCenter={initialMapCenter}
isLayerTOCOpen={true}
layerList={initialLayerList}
onInitialRenderComplete={props.onInitialRenderComplete}
onApiAvailable={(api) => {
api.onRenderComplete$.pipe(first()).subscribe(() => {
if (isMounted()) {
props.onInitialRenderComplete();
}
});
}}
isSharable={false}
/>
);

View file

@ -9,8 +9,10 @@ import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import type { ExpressionRenderDefinition } from '@kbn/expressions-plugin/common';
import { dynamic } from '@kbn/shared-ux-utility';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { TileMapVisRenderValue } from './tile_map_fn';
import { TILE_MAP_RENDER } from './types';
import { getAnalytics, getCoreI18n, getTheme } from '../../kibana_services';
const Component = dynamic(async () => {
const { TileMapVisualization } = await import('./tile_map_visualization');
@ -37,6 +39,15 @@ export const tileMapRenderer = {
visConfig,
};
render(<Component {...props} />, domNode);
render(
<KibanaRenderContextProvider
analytics={getAnalytics()}
i18n={getCoreI18n()}
theme={getTheme()}
>
<Component {...props} />
</KibanaRenderContextProvider>,
domNode
);
},
} as ExpressionRenderDefinition<TileMapVisRenderValue>;

View file

@ -6,10 +6,12 @@
*/
import React, { useMemo } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import { first } from 'rxjs';
import type { Filter } from '@kbn/es-query';
import type { Query, TimeRange } from '@kbn/es-query';
import type { TileMapVisConfig } from './types';
import { MapComponent } from '../../embeddable/map_component';
import { MapRenderer } from '../../react_embeddable/map_renderer';
import { createTileMapLayerDescriptor } from '../../classes/layers/create_tile_map_layer_descriptor';
interface Props {
@ -21,6 +23,7 @@ interface Props {
}
export function TileMapVisualization(props: Props) {
const isMounted = useMountedState();
const initialMapCenter = useMemo(() => {
return {
lat: props.visConfig.mapCenter[0],
@ -37,7 +40,7 @@ export function TileMapVisualization(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<MapComponent
<MapRenderer
title={props.visConfig.layerDescriptorParams.label}
filters={props.filters}
query={props.query}
@ -45,7 +48,13 @@ export function TileMapVisualization(props: Props) {
mapCenter={initialMapCenter}
isLayerTOCOpen={true}
layerList={initialLayerList}
onInitialRenderComplete={props.onInitialRenderComplete}
onApiAvailable={(api) => {
api.onRenderComplete$.pipe(first()).subscribe(() => {
if (isMounted()) {
props.onInitialRenderComplete();
}
});
}}
isSharable={false}
/>
);

View file

@ -5,25 +5,20 @@
* 2.0.
*/
import React, { Component, RefObject } from 'react';
import React, { useEffect, useRef } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import { Subscription } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { EuiLoadingChart } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public';
import type { LayerDescriptor } from '../../common/descriptor_types';
import { INITIAL_LOCATION } from '../../common';
import { MapEmbeddable } from '../embeddable';
import { INITIAL_LOCATION, MAP_SAVED_OBJECT_TYPE } from '../../common';
import { createBasemapLayerDescriptor } from '../classes/layers/create_basemap_layer_descriptor';
import { MapApi, MapSerializedState } from '../react_embeddable/types';
export interface Props {
passiveLayer: LayerDescriptor;
onRenderComplete?: () => void;
}
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.
@ -31,88 +26,76 @@ interface State {
* 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<Props, State> {
private _isMounted = false;
private _prevPassiveLayer = this.props.passiveLayer;
private readonly _embeddableRef: RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>();
private _onRenderSubscription: Subscription | undefined;
export function PassiveMap(props: Props) {
const isMounted = useMountedState();
const mapApiRef = useRef<MapApi | undefined>(undefined);
const beforeApiReadyPassiveLayerRef = useRef<LayerDescriptor | undefined>(undefined);
const onRenderCompleteSubscriptionRef = useRef<Subscription | undefined>(undefined);
state: State = { mapEmbeddable: null };
componentDidMount() {
this._isMounted = true;
this._setupEmbeddable();
}
componentWillUnmount() {
this._isMounted = false;
if (this.state.mapEmbeddable) {
this.state.mapEmbeddable.destroy();
useEffect(() => {
if (mapApiRef.current) {
mapApiRef.current.updateLayerById(props.passiveLayer);
} else {
beforeApiReadyPassiveLayerRef.current = props.passiveLayer;
}
if (this._onRenderSubscription) {
this._onRenderSubscription.unsubscribe();
}
}
}, [props.passiveLayer]);
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 = new MapEmbeddable(
{
editable: false,
},
{
id: uuidv4(),
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
},
useEffect(() => {
return () => {
if (onRenderCompleteSubscriptionRef.current) {
onRenderCompleteSubscriptionRef.current.unsubscribe();
}
);
};
}, []);
if (this.props.onRenderComplete) {
this._onRenderSubscription = mapEmbeddable.getOnRenderComplete$().subscribe(() => {
if (this._isMounted && this.props.onRenderComplete) {
this.props.onRenderComplete();
}
});
}
if (this._isMounted) {
mapEmbeddable.setIsSharable(false);
this.setState({ mapEmbeddable }, () => {
if (this.state.mapEmbeddable && this._embeddableRef.current) {
this.state.mapEmbeddable.render(this._embeddableRef.current);
}
});
}
}
render() {
if (!this.state.mapEmbeddable) {
return <EuiLoadingChart mono size="l" />;
}
return <div className="mapEmbeddableContainer" ref={this._embeddableRef} />;
}
return (
<div className="mapEmbeddableContainer">
<ReactEmbeddableRenderer<MapSerializedState, MapApi>
type={MAP_SAVED_OBJECT_TYPE}
getParentApi={() => ({
getSerializedStateForChild: () => {
const basemapLayerDescriptor = createBasemapLayerDescriptor();
const intialLayers = basemapLayerDescriptor ? [basemapLayerDescriptor] : [];
return {
rawState: {
attributes: {
title: '',
layerListJSON: JSON.stringify([...intialLayers, 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
},
isSharable: false,
},
references: [],
};
},
})}
onApiAvailable={(api) => {
mapApiRef.current = api;
if (beforeApiReadyPassiveLayerRef.current) {
api.updateLayerById(beforeApiReadyPassiveLayerRef.current);
}
if (props.onRenderComplete) {
onRenderCompleteSubscriptionRef.current = api.onRenderComplete$.subscribe(() => {
if (isMounted() && props.onRenderComplete) {
props.onRenderComplete();
}
});
}
}}
hidePanelChrome={true}
/>
</div>
);
}

View file

@ -1,136 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SavedObjectReference } from '@kbn/core/types';
import type { ResolvedSimpleSavedObject } from '@kbn/core/public';
import { AttributeService } from '@kbn/embeddable-plugin/public';
import type { OnSaveProps } from '@kbn/saved-objects-plugin/public';
import { SavedObjectCommon } from '@kbn/saved-objects-finder-plugin/common';
import type { MapAttributes } from '../common/content_management';
import { MAP_EMBEDDABLE_NAME, MAP_SAVED_OBJECT_TYPE } from '../common/constants';
import { getCoreOverlays, getEmbeddableService } from './kibana_services';
import { extractReferences, injectReferences } from '../common/migrations/references';
import { getMapClient, checkForDuplicateTitle } from './content_management';
import { MapByValueInput, MapByReferenceInput } from './embeddable/types';
export interface SharingSavedObjectProps {
outcome?: ResolvedSimpleSavedObject['outcome'];
aliasTargetId?: ResolvedSimpleSavedObject['alias_target_id'];
aliasPurpose?: ResolvedSimpleSavedObject['alias_purpose'];
sourceId?: string;
}
type MapDoc = MapAttributes & {
references?: SavedObjectReference[];
};
export interface MapUnwrapMetaInfo {
sharingSavedObjectProps: SharingSavedObjectProps;
// Is this map managed by the system?
managed: boolean;
}
export type MapAttributeService = AttributeService<
MapDoc,
MapByValueInput,
MapByReferenceInput,
MapUnwrapMetaInfo
>;
export const savedObjectToEmbeddableAttributes = (
savedObject: SavedObjectCommon<MapAttributes>
) => {
const { attributes } = injectReferences(savedObject);
return {
...attributes,
references: savedObject.references,
};
};
let mapAttributeService: MapAttributeService | null = null;
export function getMapAttributeService(): MapAttributeService {
if (mapAttributeService) {
return mapAttributeService;
}
mapAttributeService = getEmbeddableService().getAttributeService<
MapDoc,
MapByValueInput,
MapByReferenceInput,
MapUnwrapMetaInfo
>(MAP_SAVED_OBJECT_TYPE, {
saveMethod: async (attributes: MapDoc, savedObjectId?: string) => {
// AttributeService "attributes" contains "references" as a child.
// SavedObjectClient "attributes" uses "references" as a sibling.
// https://github.com/elastic/kibana/issues/83133
const savedObjectClientReferences = attributes.references;
const savedObjectClientAttributes = { ...attributes };
delete savedObjectClientAttributes.references;
const { attributes: updatedAttributes, references } = extractReferences({
attributes: savedObjectClientAttributes,
references: savedObjectClientReferences,
});
const {
item: { id },
} = await (savedObjectId
? getMapClient().update({
id: savedObjectId,
data: updatedAttributes,
options: { references },
})
: getMapClient().create({ data: updatedAttributes, options: { references } }));
return { id };
},
unwrapMethod: async (
savedObjectId: string
): Promise<{
attributes: MapDoc;
metaInfo: MapUnwrapMetaInfo;
}> => {
const {
item: savedObject,
meta: { outcome, aliasPurpose, aliasTargetId },
} = await getMapClient<MapAttributes>().get(savedObjectId);
if (savedObject.error) {
throw savedObject.error;
}
return {
attributes: savedObjectToEmbeddableAttributes(savedObject),
metaInfo: {
sharingSavedObjectProps: {
aliasTargetId,
outcome,
aliasPurpose,
sourceId: savedObjectId,
},
managed: Boolean(savedObject.managed),
},
};
},
checkForDuplicateTitle: (props: OnSaveProps) => {
return checkForDuplicateTitle(
{
title: props.newTitle,
copyOnSave: false,
lastSavedTitle: '',
isTitleDuplicateConfirmed: props.isTitleDuplicateConfirmed,
getDisplayName: () => MAP_EMBEDDABLE_NAME,
onTitleDuplicate: props.onTitleDuplicate,
},
{
overlays: getCoreOverlays(),
}
);
},
});
return mapAttributeService;
}

View file

@ -25,6 +25,7 @@ import type { VisualizationsSetup, VisualizationsStart } from '@kbn/visualizatio
import type { Plugin as ExpressionsPublicPlugin } from '@kbn/expressions-plugin/public';
import { VISUALIZE_GEO_FIELD_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public';
import { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public';
import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
import type { MapsEmsPluginPublicStart } from '@kbn/maps-ems-plugin/public';
@ -69,11 +70,10 @@ import {
suggestEMSTermJoinConfig,
} from './api';
import { MapsXPackConfig, MapsConfigType } from '../config';
import { MapEmbeddableFactory } from './embeddable/map_embeddable_factory';
import { filterByMapExtentAction } from './trigger_actions/filter_by_map_extent/action';
import { synchronizeMovementAction } from './trigger_actions/synchronize_movement/action';
import { visualizeGeoFieldAction } from './trigger_actions/visualize_geo_field_action';
import { APP_NAME, APP_ICON_SOLUTION, APP_ID, MAP_SAVED_OBJECT_TYPE } from '../common/constants';
import { APP_NAME, APP_ICON_SOLUTION, APP_ID } from '../common/constants';
import { getMapsVisTypeAlias } from './maps_vis_type_alias';
import { featureCatalogueEntry } from './feature_catalogue_entry';
import {
@ -81,15 +81,15 @@ import {
setMapAppConfig,
setSpaceId,
setStartServices,
untilPluginStartServicesReady,
} from './kibana_services';
import { MapInspectorView } from './inspector/map_adapter/map_inspector_view';
import { VectorTileInspectorView } from './inspector/vector_tile_adapter/vector_tile_inspector_view';
import { PassiveMapLazy, setupLensChoroplethChart } from './lens';
import { CONTENT_ID, LATEST_VERSION, MapAttributes } from '../common/content_management';
import { savedObjectToEmbeddableAttributes } from './map_attribute_service';
import { MapByValueInput } from './embeddable';
import { MapComponentLazy } from './embeddable/map_component_lazy';
import { CONTENT_ID, LATEST_VERSION } from '../common/content_management';
import { setupMapEmbeddable } from './react_embeddable/setup_map_embeddable';
import { MapRendererLazy } from './react_embeddable/map_renderer_lazy';
export interface MapsPluginSetupDependencies {
cloud?: CloudSetup;
@ -112,6 +112,7 @@ export interface MapsPluginStartDependencies {
data: DataPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
embeddable: EmbeddableStart;
embeddableEnhanced?: EmbeddableEnhancedPluginStart;
fieldFormats: FieldFormatsStart;
fileUpload: FileUploadPluginStart;
inspector: InspectorStartContract;
@ -194,7 +195,6 @@ export class MapsPlugin
plugins.home.featureCatalogue.register(featureCatalogueEntry);
}
plugins.visualizations.registerAlias(getMapsVisTypeAlias());
plugins.embeddable.registerEmbeddableFactory(MAP_SAVED_OBJECT_TYPE, new MapEmbeddableFactory());
core.application.register({
id: APP_ID,
@ -204,14 +204,18 @@ export class MapsPlugin
euiIconType: APP_ICON_SOLUTION,
category: DEFAULT_APP_CATEGORIES.kibana,
async mount(params: AppMountParameters) {
const [coreStart, { savedObjectsTagging, spaces }] = await core.getStartServices();
const [, startServices, { renderApp }] = await Promise.all([
untilPluginStartServicesReady(),
core.getStartServices(),
import('./render_app'),
]);
const [coreStart, { savedObjectsTagging, spaces }] = startServices;
const UsageTracker =
plugins.usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment;
const activeSpace = await spaces?.getActiveSpace();
if (activeSpace) {
setSpaceId(activeSpace.id);
}
const { renderApp } = await import('./render_app');
return renderApp(params, { coreStart, AppUsageTracker: UsageTracker, savedObjectsTagging });
},
});
@ -224,18 +228,7 @@ export class MapsPlugin
name: APP_NAME,
});
plugins.embeddable.registerSavedObjectToPanelMethod<MapAttributes, MapByValueInput>(
CONTENT_ID,
(savedObject) => {
if (!savedObject.managed) {
return { savedObjectId: savedObject.id };
}
return {
attributes: savedObjectToEmbeddableAttributes(savedObject),
};
}
);
setupMapEmbeddable(plugins.embeddable);
setupLensChoroplethChart(core, plugins.expressions, plugins.lens);
@ -273,7 +266,7 @@ export class MapsPlugin
return {
createLayerDescriptors,
suggestEMSTermJoinConfig,
Map: MapComponentLazy,
Map: MapRendererLazy,
PassiveMap: PassiveMapLazy,
};
}

View file

@ -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.
*/
import type { Filter } from '@kbn/es-query';
import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public';
import { ACTION_GLOBAL_APPLY_FILTER } from '@kbn/unified-search-plugin/public';
import { VALUE_CLICK_TRIGGER } from '@kbn/embeddable-plugin/public';
import { RawValue } from '../../common/constants';
import type { MapApi } from './types';
import { getUiActions } from '../kibana_services';
import { isUrlDrilldown, toValueClickDataFormat } from '../trigger_actions/trigger_utils';
export function initializeActionHandlers(getApi: () => MapApi | undefined) {
function getActionContext() {
const trigger = getUiActions().getTrigger(APPLY_FILTER_TRIGGER);
if (!trigger) {
throw new Error('Unable to get context, could not locate trigger');
}
return {
embeddable: getApi(),
trigger,
} as ActionExecutionContext;
}
return {
addFilters: async (filters: Filter[], actionId: string = ACTION_GLOBAL_APPLY_FILTER) => {
const executeContext = {
...getActionContext(),
filters,
};
const action = getUiActions().getAction(actionId);
if (!action) {
throw new Error('Unable to apply filter, could not locate action');
}
action.execute(executeContext);
},
getActionContext,
getFilterActions: async () => {
const filterActions = await getUiActions().getTriggerCompatibleActions(APPLY_FILTER_TRIGGER, {
embeddable: getApi(),
filters: [],
});
const valueClickActions = await getUiActions().getTriggerCompatibleActions(
VALUE_CLICK_TRIGGER,
{
embeddable: getApi(),
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)];
},
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 = {
...getActionContext(),
data: {
data: toValueClickDataFormat(key, value),
},
};
action.execute(executeContext);
},
};
}

View file

@ -0,0 +1,228 @@
/*
* 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 _ from 'lodash';
import { ACTION_GLOBAL_APPLY_FILTER } from '@kbn/unified-search-plugin/public';
import { i18n } from '@kbn/i18n';
import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import { BehaviorSubject } from 'rxjs';
import { getPanelTitle, StateComparators } from '@kbn/presentation-publishing';
import { createExtentFilter } from '../../common/elasticsearch_util';
import { SavedMap } from '../routes/map_page';
import { mapEmbeddablesSingleton } from './map_embeddables_singleton';
import {
getGeoFieldNames,
getGoto,
getMapCenter,
getMapExtent,
getMapReady,
getMapSettings,
getMapZoom,
} from '../selectors/map_selectors';
import { setGotoWithCenter, setMapSettings } from '../actions';
import { MapExtent } from '../../common/descriptor_types';
import { getUiActions } from '../kibana_services';
import { getGeoFieldsLabel } from './get_geo_fields_label';
import { MapApi, MapSerializedState } from './types';
import { setOnMapMove } from '../reducers/non_serializable_instances';
export function initializeCrossPanelActions({
controlledBy,
getActionContext,
getApi,
savedMap,
state,
uuid,
}: {
controlledBy: string;
getActionContext: () => ActionExecutionContext;
getApi: () => MapApi | undefined;
savedMap: SavedMap;
state: MapSerializedState;
uuid: string;
}) {
const isMovementSynchronized$ = new BehaviorSubject<boolean | undefined>(
state.isMovementSynchronized
);
function getIsMovementSynchronized() {
return isMovementSynchronized$.value ?? true;
}
function setIsMovementSynchronized(next: boolean) {
isMovementSynchronized$.next(next);
}
const isFilterByMapExtent$ = new BehaviorSubject<boolean | undefined>(state.filterByMapExtent);
function getIsFilterByMapExtent() {
return isFilterByMapExtent$.value ?? false;
}
function setIsFilterByMapExtent(next: boolean) {
isFilterByMapExtent$.next(next);
}
let prevMapExtent: MapExtent | undefined;
function mapSyncHandler(lat: number, lon: number, zoom: number) {
// auto fit to bounds is not compatable with map synchronization
// auto fit to bounds may cause map location to never stablize and bound back and forth between bounds on different maps
if (getMapSettings(savedMap.getStore().getState()).autoFitToDataBounds) {
savedMap.getStore().dispatch(setMapSettings({ autoFitToDataBounds: false }));
}
savedMap.getStore().dispatch(setGotoWithCenter({ lat, lon, zoom }));
}
function gotoSynchronizedLocation() {
const syncedLocation = mapEmbeddablesSingleton.getLocation();
if (syncedLocation) {
// set map to synchronized view
mapSyncHandler(syncedLocation.lat, syncedLocation.lon, syncedLocation.zoom);
return;
}
if (!getMapReady(savedMap.getStore().getState())) {
// Initialize synchronized view to map's goto
// Use goto because un-rendered map will not have accurate mapCenter and mapZoom.
const goto = getGoto(savedMap.getStore().getState());
if (goto && goto.center) {
mapEmbeddablesSingleton.setLocation(
uuid,
goto.center.lat,
goto.center.lon,
goto.center.zoom
);
return;
}
}
// Initialize synchronized view to map's view
const center = getMapCenter(savedMap.getStore().getState());
const zoom = getMapZoom(savedMap.getStore().getState());
mapEmbeddablesSingleton.setLocation(uuid, center.lat, center.lon, zoom);
}
// debounce to fix timing issue for dashboard with multiple maps with synchronized movement and filter by map extent enabled
const setMapExtentFilter = _.debounce(() => {
const mapExtent = getMapExtent(savedMap.getStore().getState());
const geoFieldNames = mapEmbeddablesSingleton.getGeoFieldNames();
if (mapExtent === undefined || geoFieldNames.length === 0) {
return;
}
prevMapExtent = mapExtent;
const mapExtentFilter = createExtentFilter(mapExtent, geoFieldNames);
mapExtentFilter.meta.controlledBy = controlledBy;
mapExtentFilter.meta.alias = i18n.translate('xpack.maps.embeddable.boundsFilterLabel', {
defaultMessage: '{geoFieldsLabel} within map bounds',
values: { geoFieldsLabel: getGeoFieldsLabel(geoFieldNames) },
});
const executeContext = {
...getActionContext(),
filters: [mapExtentFilter],
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);
}, 100);
function clearMapExtentFilter() {
prevMapExtent = undefined;
const executeContext = {
...getActionContext(),
filters: [],
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);
}
mapEmbeddablesSingleton.register(uuid, {
getTitle: () => {
const mapApi = getApi();
const title = mapApi ? getPanelTitle(mapApi) : undefined;
return title
? title
: i18n.translate('xpack.maps.embeddable.untitleMap', {
defaultMessage: 'Untitled map',
});
},
onLocationChange: mapSyncHandler,
getIsMovementSynchronized,
setIsMovementSynchronized: (isMovementSynchronized: boolean) => {
setIsMovementSynchronized(isMovementSynchronized);
if (isMovementSynchronized) {
gotoSynchronizedLocation();
} else if (!isMovementSynchronized && savedMap.getAutoFitToBounds()) {
// restore autoFitToBounds when isMovementSynchronized disabled
savedMap.getStore().dispatch(setMapSettings({ autoFitToDataBounds: true }));
}
},
getIsFilterByMapExtent,
setIsFilterByMapExtent: (isFilterByMapExtent: boolean) => {
setIsFilterByMapExtent(isFilterByMapExtent);
if (isFilterByMapExtent) {
setMapExtentFilter();
} else {
clearMapExtentFilter();
}
},
getGeoFieldNames: () => {
return getGeoFieldNames(savedMap.getStore().getState());
},
});
if (getIsMovementSynchronized()) {
gotoSynchronizedLocation();
}
// Passing callback into redux store instead of regular pattern of getting redux state changes for performance reasons
savedMap.getStore().dispatch(
setOnMapMove((lat: number, lon: number, zoom: number) => {
if (getIsMovementSynchronized()) {
mapEmbeddablesSingleton.setLocation(uuid, lat, lon, zoom);
}
})
);
const unsubscribeFromStore = savedMap.getStore().subscribe(() => {
if (!getMapReady(savedMap.getStore().getState())) {
return;
}
if (
getIsFilterByMapExtent() &&
!_.isEqual(prevMapExtent, getMapExtent(savedMap.getStore().getState()))
) {
setMapExtentFilter();
}
});
return {
cleanup: () => {
mapEmbeddablesSingleton.unregister(uuid);
unsubscribeFromStore();
},
comparators: {
isMovementSynchronized: [isMovementSynchronized$, setIsMovementSynchronized],
filterByMapExtent: [isFilterByMapExtent$, setIsFilterByMapExtent],
} as StateComparators<Pick<MapSerializedState, 'isMovementSynchronized' | 'filterByMapExtent'>>,
getIsFilterByMapExtent,
serialize: () => {
return {
isMovementSynchronized: isMovementSynchronized$.value,
filterByMapExtent: isFilterByMapExtent$.value,
};
},
};
}

View file

@ -0,0 +1,133 @@
/*
* 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 { DataView } from '@kbn/data-plugin/common';
import { createMapStore } from '../reducers/store';
import { initializeDataViews } from './initialize_data_views';
import { createLayerDescriptor } from '../classes/sources/es_search_source';
import { ES_GEO_FIELD_TYPE } from '../../common/constants';
import { skip } from 'rxjs';
jest.mock('../kibana_services', () => {
return {
getIsDarkMode() {
return false;
},
getMapsCapabilities() {
return { save: true };
},
getShowMapsInspectorAdapter() {
return false;
},
getEMSSettings() {
return {
isEMSUrlSet() {
return false;
},
};
},
};
});
jest.mock('../index_pattern_util', () => {
return {
getIndexPatternsFromIds: async (ids: string[]) => {
return ids.length
? ids.map(
(id) =>
({
id,
} as unknown as DataView)
)
: [];
},
};
});
describe('dataViews$', () => {
const onEmitMock = jest.fn();
beforeEach(() => {
onEmitMock.mockReset();
});
test('Should emit when data view added', async () => {
const dataViewApi = initializeDataViews(createMapStore());
const subscription = dataViewApi.dataViews.pipe(skip(1)).subscribe(onEmitMock);
dataViewApi.setLayerList([
createLayerDescriptor({
indexPatternId: '1234',
geoFieldName: 'location',
geoFieldType: ES_GEO_FIELD_TYPE.GEO_POINT,
}),
]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onEmitMock.mock.calls).toHaveLength(1);
expect(onEmitMock.mock.calls[0][0]).toEqual([
{
id: '1234',
},
]);
subscription.unsubscribe();
});
test('Should emit when data view removed', async () => {
const dataViewApi = initializeDataViews(createMapStore());
dataViewApi.setLayerList([
createLayerDescriptor({
indexPatternId: '1234',
geoFieldName: 'location',
geoFieldType: ES_GEO_FIELD_TYPE.GEO_POINT,
}),
]);
const subscription = dataViewApi.dataViews.pipe(skip(1)).subscribe(onEmitMock);
dataViewApi.setLayerList([]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onEmitMock.mock.calls).toHaveLength(1);
expect(onEmitMock.mock.calls[0][0]).toEqual([]);
subscription.unsubscribe();
});
test('Should emit not emit when data view ids do not change', async () => {
const dataViewApi = initializeDataViews(createMapStore());
dataViewApi.setLayerList([
createLayerDescriptor({
indexPatternId: '1234',
geoFieldName: 'location',
geoFieldType: ES_GEO_FIELD_TYPE.GEO_POINT,
}),
createLayerDescriptor({
indexPatternId: '4567',
geoFieldName: 'location',
geoFieldType: ES_GEO_FIELD_TYPE.GEO_POINT,
}),
]);
await new Promise((resolve) => setTimeout(resolve, 0));
const subscription = dataViewApi.dataViews.pipe(skip(1)).subscribe(onEmitMock);
dataViewApi.setLayerList([
createLayerDescriptor({
indexPatternId: '4567',
geoFieldName: 'location',
geoFieldType: ES_GEO_FIELD_TYPE.GEO_POINT,
}),
createLayerDescriptor({
indexPatternId: '1234',
geoFieldName: 'location',
geoFieldType: ES_GEO_FIELD_TYPE.GEO_POINT,
}),
]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onEmitMock).not.toHaveBeenCalled();
subscription.unsubscribe();
});
});

View file

@ -0,0 +1,67 @@
/*
* 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 { BehaviorSubject } from 'rxjs';
import type { DataView } from '@kbn/data-plugin/common';
import { isEqual } from 'lodash';
import { LayerDescriptor } from '../../common';
import { replaceLayerList, updateLayerDescriptor } from '../actions';
import { MapStore } from '../reducers/store';
import { getIndexPatternsFromIds } from '../index_pattern_util';
import { getMapSettings, getQueryableUniqueIndexPatternIds } from '../selectors/map_selectors';
import { autoFitToBounds, syncDataForLayerId } from '../actions/data_request_actions';
export function initializeDataViews(store: MapStore) {
const dataViews$ = new BehaviorSubject<DataView[] | undefined>(undefined);
let dataViewsFetchToken: symbol | undefined;
async function updateDataViews() {
const queryableDataViewIds = getQueryableUniqueIndexPatternIds(store.getState());
const prevDataViewIds = dataViews$.getValue()?.map((dataView) => {
return dataView.id;
});
if (isEqual(queryableDataViewIds.sort(), prevDataViewIds?.sort())) {
return;
}
const currentDataViewsFetchToken = Symbol();
dataViewsFetchToken = currentDataViewsFetchToken;
const dataViews = await getIndexPatternsFromIds(queryableDataViewIds);
// ignore responses from obsolete requests
if (currentDataViewsFetchToken !== dataViewsFetchToken) {
return;
}
dataViews$.next(dataViews);
}
updateDataViews();
const syncLayerTokens: Record<string, symbol> = {};
return {
dataViews: dataViews$,
setLayerList(layerList: LayerDescriptor[]) {
store.dispatch<any>(replaceLayerList(layerList));
updateDataViews();
},
updateLayerById: (layerDescriptor: LayerDescriptor) => {
store.dispatch<any>(updateLayerDescriptor(layerDescriptor));
updateDataViews();
(async () => {
const currentSyncLayerToken = Symbol();
syncLayerTokens[layerDescriptor.id] = currentSyncLayerToken;
await store.dispatch<any>(syncDataForLayerId(layerDescriptor.id, false));
// stop processing responses from obsolete requests
if (currentSyncLayerToken !== syncLayerTokens[layerDescriptor.id]) {
return;
}
if (getMapSettings(store.getState()).autoFitToDataBounds) {
store.dispatch<any>(autoFitToBounds());
}
})();
},
};
}

View file

@ -0,0 +1,48 @@
/*
* 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 { apiHasAppContext } from '@kbn/presentation-publishing';
import { APP_ID, getEditPath, getFullPath, MAP_EMBEDDABLE_NAME } from '../../common/constants';
import { getEmbeddableService, getHttp, getMapsCapabilities } from '../kibana_services';
import { MapSerializedState } from './types';
export function initializeEditApi(
uuid: string,
getState: () => MapSerializedState,
parentApi?: unknown,
savedObjectId?: string
) {
if (!parentApi || !apiHasAppContext(parentApi)) {
return {};
}
const parentApiContext = parentApi.getAppContext();
return {
getTypeDisplayName: () => {
return MAP_EMBEDDABLE_NAME;
},
onEdit: async () => {
const stateTransfer = getEmbeddableService().getStateTransfer();
await stateTransfer.navigateToEditor(APP_ID, {
path: getEditPath(savedObjectId),
state: {
embeddableId: uuid,
valueInput: getState(),
originatingApp: parentApiContext.currentAppId,
originatingPath: parentApiContext.getCurrentPath?.(),
},
});
},
isEditingEnabled: () => {
return getMapsCapabilities().save as boolean;
},
getEditHref: async () => {
return getHttp().basePath.prepend(getFullPath(savedObjectId));
},
};
}

View file

@ -0,0 +1,82 @@
/*
* 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 { FetchContext, fetch$ } from '@kbn/presentation-publishing';
import { Query } from '@kbn/es-query';
import { MapExtent } from '../../common/descriptor_types';
import { getSearchService } from '../kibana_services';
import { MapStore } from '../reducers/store';
import { MapApi } from './types';
import { setMapSettings, setQuery } from '../actions';
function getIsRestore(searchSessionId?: string) {
if (!searchSessionId) {
return false;
}
const searchSessionOptions = getSearchService().session.getSearchOptions(searchSessionId);
return searchSessionOptions ? searchSessionOptions.isRestore : false;
}
export function initializeFetch({
api,
controlledBy,
getIsFilterByMapExtent,
searchSessionMapBuffer,
store,
}: {
api: MapApi;
controlledBy: string;
getIsFilterByMapExtent: () => boolean;
searchSessionMapBuffer?: MapExtent;
store: MapStore;
}) {
let prevIsRestore: boolean | undefined;
const fetchSubscription = fetch$(api).subscribe((fetchContext: FetchContext) => {
// 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).
const searchSessionId = getIsFilterByMapExtent() ? undefined : fetchContext.searchSessionId;
const isRestore = getIsRestore(searchSessionId);
// Map can not be interacted with when viewing session restore.
// Session restore only show data for cached extent and new data can not be fetch
if (isRestore !== prevIsRestore) {
prevIsRestore = isRestore;
store.dispatch(
setMapSettings({
disableInteractive: isRestore,
hideToolbarOverlay: isRestore,
})
);
}
store.dispatch<any>(
setQuery({
filters: fetchContext.filters
? fetchContext.filters.filter(
(filter) => !filter.meta.disabled && filter.meta.controlledBy !== controlledBy
)
: [],
query: fetchContext.query as Query | undefined,
timeFilters: fetchContext.timeRange,
timeslice: fetchContext.timeslice
? { from: fetchContext.timeslice[0], to: fetchContext.timeslice[1] }
: undefined,
clearTimeslice: fetchContext.timeslice === undefined,
forceRefresh: fetchContext.isReload,
searchSessionId,
searchSessionMapBuffer: isRestore ? searchSessionMapBuffer : undefined,
})
);
});
return () => {
fetchSubscription.unsubscribe();
};
}

View file

@ -0,0 +1,279 @@
/*
* 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 { BehaviorSubject, debounceTime, filter, map, Subscription } from 'rxjs';
import fastIsEqual from 'fast-deep-equal';
import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing';
import { KibanaExecutionContext } from '@kbn/core-execution-context-common';
import { PaletteRegistry } from '@kbn/coloring';
import { AggregateQuery, Filter, Query } from '@kbn/es-query';
import { MapCenterAndZoom } from '../../common/descriptor_types';
import { APP_ID, getEditPath, RENDER_TIMEOUT } from '../../common/constants';
import { MapStoreState } from '../reducers/store';
import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors';
import {
getLayerList,
getLayerListRaw,
getMapBuffer,
getMapCenter,
getMapReady,
getMapZoom,
isMapLoading,
} from '../selectors/map_selectors';
import {
setEmbeddableSearchContext,
setExecutionContext,
setGotoWithCenter,
setHiddenLayers,
setIsLayerTOCOpen,
setMapSettings,
setOpenTOCDetails,
setQuery,
setReadOnly,
} from '../actions';
import type { MapSerializedState } from './types';
import { getCharts, getExecutionContextService } from '../kibana_services';
import {
EventHandlers,
getInspectorAdapters,
setChartsPaletteServiceGetColor,
setEventHandlers,
} from '../reducers/non_serializable_instances';
import { SavedMap } from '../routes';
function getMapCenterAndZoom(state: MapStoreState) {
return {
...getMapCenter(state),
zoom: getMapZoom(state),
};
}
function getHiddenLayerIds(state: MapStoreState) {
return getLayerListRaw(state)
.filter((layer) => !layer.visible)
.map((layer) => layer.id);
}
export function initializeReduxSync({
savedMap,
state,
syncColors$,
uuid,
}: {
savedMap: SavedMap;
state: MapSerializedState;
syncColors$?: PublishingSubject<boolean | undefined>;
uuid: string;
}) {
const store = savedMap.getStore();
// initializing comparitor publishing subjects to state instead of store state values
// because store is not settled until map is rendered and mapReady is true
const hiddenLayers$ = new BehaviorSubject<string[]>(
state.hiddenLayers ?? getHiddenLayerIds(store.getState())
);
const isLayerTOCOpen$ = new BehaviorSubject<boolean>(
state.isLayerTOCOpen ?? getIsLayerTOCOpen(store.getState())
);
const mapCenterAndZoom$ = new BehaviorSubject<MapCenterAndZoom>(
state.mapCenter ?? getMapCenterAndZoom(store.getState())
);
const openTOCDetails$ = new BehaviorSubject<string[]>(
state.openTOCDetails ?? getOpenTOCDetails(store.getState())
);
const dataLoading$ = new BehaviorSubject<boolean | undefined>(undefined);
const unsubscribeFromStore = store.subscribe(() => {
if (!getMapReady(store.getState())) {
return;
}
const nextHiddenLayers = getHiddenLayerIds(store.getState());
if (!fastIsEqual(hiddenLayers$.value, nextHiddenLayers)) {
hiddenLayers$.next(nextHiddenLayers);
}
const nextIsLayerTOCOpen = getIsLayerTOCOpen(store.getState());
if (isLayerTOCOpen$.value !== nextIsLayerTOCOpen) {
isLayerTOCOpen$.next(nextIsLayerTOCOpen);
}
const nextMapCenterAndZoom = getMapCenterAndZoom(store.getState());
if (!fastIsEqual(mapCenterAndZoom$.value, nextMapCenterAndZoom)) {
mapCenterAndZoom$.next(nextMapCenterAndZoom);
}
const nextOpenTOCDetails = getOpenTOCDetails(store.getState());
if (!fastIsEqual(openTOCDetails$.value, nextOpenTOCDetails)) {
openTOCDetails$.next(nextOpenTOCDetails);
}
const nextIsMapLoading = isMapLoading(store.getState());
if (nextIsMapLoading !== dataLoading$.value) {
dataLoading$.next(nextIsMapLoading);
}
});
store.dispatch(setReadOnly(true));
store.dispatch(
setMapSettings({
keydownScrollZoom: true,
showTimesliderToggleButton: false,
})
);
store.dispatch(setExecutionContext(getExecutionContext(uuid, state.savedObjectId)));
const filters$ = new BehaviorSubject<Filter[] | undefined>(undefined);
const query$ = new BehaviorSubject<AggregateQuery | Query | undefined>(undefined);
const mapStateJSON = savedMap.getAttributes().mapStateJSON;
if (mapStateJSON) {
try {
const mapState = JSON.parse(mapStateJSON);
if (mapState.filters) {
filters$.next(mapState.filters);
}
if (mapState.query) {
query$.next(mapState.query);
}
store.dispatch(
setEmbeddableSearchContext({
filters: mapState.filters,
query: mapState.query,
})
);
} catch (e) {
// ignore malformed mapStateJSON, not a critical error for viewing map - map will just use defaults
}
}
let syncColorsSubscription: Subscription | undefined;
let syncColorsSymbol: symbol | undefined;
if (syncColors$) {
syncColorsSubscription = syncColors$.subscribe(async (syncColors: boolean | undefined) => {
const currentSyncColorsSymbol = Symbol();
syncColorsSymbol = currentSyncColorsSymbol;
const chartsPaletteServiceGetColor = syncColors
? await getChartsPaletteServiceGetColor()
: null;
if (syncColorsSymbol === currentSyncColorsSymbol) {
store.dispatch(setChartsPaletteServiceGetColor(chartsPaletteServiceGetColor));
}
});
}
return {
cleanup: () => {
if (syncColorsSubscription) syncColorsSubscription.unsubscribe();
unsubscribeFromStore();
},
api: {
dataLoading: dataLoading$,
filters$,
getInspectorAdapters: () => {
return getInspectorAdapters(store.getState());
},
getLayerList: () => {
return getLayerList(store.getState());
},
onRenderComplete$: dataLoading$.pipe(
filter((isDataLoading) => typeof isDataLoading === 'boolean' && !isDataLoading),
debounceTime(RENDER_TIMEOUT),
map(() => {
// Observable notifies subscriber when rendering is complete
// Return void to not expose internal implemenation details of observabale
return;
})
),
query$,
reload: () => {
store.dispatch<any>(
setQuery({
forceRefresh: true,
})
);
},
setEventHandlers: (eventHandlers: EventHandlers) => {
store.dispatch(setEventHandlers(eventHandlers));
},
},
comparators: {
// mapBuffer comparator intentionally omitted and is not part of unsaved changes check
hiddenLayers: [
hiddenLayers$,
(nextValue: string[]) => {
store.dispatch<any>(setHiddenLayers(nextValue));
},
fastIsEqual,
],
isLayerTOCOpen: [
isLayerTOCOpen$,
(nextValue: boolean) => {
store.dispatch(setIsLayerTOCOpen(nextValue));
},
],
mapCenter: [
mapCenterAndZoom$,
(nextValue: MapCenterAndZoom) => {
store.dispatch(setGotoWithCenter(nextValue));
},
fastIsEqual,
],
openTOCDetails: [
openTOCDetails$,
(nextValue: string[]) => {
store.dispatch(setOpenTOCDetails(nextValue));
},
fastIsEqual,
],
} as StateComparators<
Pick<MapSerializedState, 'hiddenLayers' | 'isLayerTOCOpen' | 'mapCenter' | 'openTOCDetails'>
>,
serialize: () => {
return {
hiddenLayers: getHiddenLayerIds(store.getState()),
isLayerTOCOpen: getIsLayerTOCOpen(store.getState()),
mapBuffer: getMapBuffer(store.getState()),
mapCenter: getMapCenterAndZoom(store.getState()),
openTOCDetails: getOpenTOCDetails(store.getState()),
};
},
};
}
function getExecutionContext(uuid: string, savedObjectId: string | undefined) {
const parentContext = getExecutionContextService().get();
const mapContext: KibanaExecutionContext = {
type: APP_ID,
name: APP_ID,
id: uuid,
url: getEditPath(savedObjectId),
};
return parentContext
? {
...parentContext,
child: mapContext,
}
: mapContext;
}
async function getChartsPaletteServiceGetColor(): Promise<((value: string) => string) | null> {
const chartsService = getCharts();
const paletteRegistry: PaletteRegistry | null = chartsService
? await chartsService.palettes.getPalettes()
: null;
if (!paletteRegistry) {
return null;
}
const paletteDefinition = paletteRegistry.get('default');
const chartConfiguration = { syncColors: true };
return (value: string) => {
const series = [{ name: value, rankAtDepth: 0, totalSeriesAtDepth: 1 }];
const color = paletteDefinition.getCategoricalColor(series, chartConfiguration);
return color ? color : '#3d3d3d';
};
}

View file

@ -0,0 +1,84 @@
/*
* 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 { SerializedPanelState } from '@kbn/presentation-containers';
import { HasLibraryTransforms } from '@kbn/presentation-publishing';
import { getCore, getCoreOverlays } from '../kibana_services';
import type { MapAttributes } from '../../common/content_management';
import { SavedMap } from '../routes/map_page';
import { checkForDuplicateTitle, getMapClient } from '../content_management';
import { MAP_EMBEDDABLE_NAME } from '../../common/constants';
import { MapSerializedState } from './types';
export function getByReferenceState(state: MapSerializedState | undefined, savedObjectId: string) {
const { attributes, ...byRefState } = state ?? {};
return {
...byRefState,
savedObjectId,
};
}
export function getByValueState(state: MapSerializedState | undefined, attributes: MapAttributes) {
const { savedObjectId, ...byValueState } = state ?? {};
return {
...byValueState,
attributes,
};
}
export function initializeLibraryTransforms(
savedMap: SavedMap,
serializeState: () => SerializedPanelState<MapSerializedState>
): HasLibraryTransforms<MapSerializedState> {
return {
canLinkToLibrary: async () => {
const { maps } = getCore().application.capabilities;
return maps.save && savedMap.getSavedObjectId() === undefined;
},
saveToLibrary: async (title: string) => {
const state = serializeState();
const {
item: { id: savedObjectId },
} = await getMapClient().create({
data: {
...(state.rawState?.attributes ?? {}),
title,
},
options: { references: state.references ?? [] },
});
return savedObjectId;
},
getByReferenceState: (libraryId: string) => {
return getByReferenceState(serializeState().rawState, libraryId);
},
checkForDuplicateTitle: async (
newTitle: string,
isTitleDuplicateConfirmed: boolean,
onTitleDuplicate: () => void
) => {
await checkForDuplicateTitle(
{
title: newTitle,
copyOnSave: false,
lastSavedTitle: '',
isTitleDuplicateConfirmed,
getDisplayName: () => MAP_EMBEDDABLE_NAME,
onTitleDuplicate,
},
{
overlays: getCoreOverlays(),
}
);
},
canUnlinkFromLibrary: async () => {
return savedMap.getSavedObjectId() !== undefined;
},
getByValueState: () => {
return getByValueState(serializeState().rawState, savedMap.getAttributes());
},
};
}

View file

@ -0,0 +1,234 @@
/*
* 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, { useEffect } from 'react';
import { Provider } from 'react-redux';
import { EuiEmptyPrompt } from '@elastic/eui';
import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public';
import { ReactEmbeddableFactory, VALUE_CLICK_TRIGGER } from '@kbn/embeddable-plugin/public';
import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common';
import {
areTriggersDisabled,
getUnchangingComparator,
initializeTimeRange,
initializeTitles,
useBatchedPublishingSubjects,
} from '@kbn/presentation-publishing';
import { BehaviorSubject } from 'rxjs';
import { apiPublishesSettings } from '@kbn/presentation-containers/interfaces/publishes_settings';
import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants';
import { inject } from '../../common/embeddable';
import type { MapApi, MapSerializedState } from './types';
import { SavedMap } from '../routes/map_page';
import { initializeReduxSync } from './initialize_redux_sync';
import {
getByReferenceState,
getByValueState,
initializeLibraryTransforms,
} from './library_transforms';
import { getEmbeddableEnhanced, getSpacesApi } from '../kibana_services';
import { initializeActionHandlers } from './initialize_action_handlers';
import { MapContainer } from '../connected_components/map_container';
import { waitUntilTimeLayersLoad$ } from '../routes/map_page/map_app/wait_until_time_layers_load';
import { initializeCrossPanelActions } from './initialize_cross_panel_actions';
import { initializeDataViews } from './initialize_data_views';
import { initializeFetch } from './initialize_fetch';
import { initializeEditApi } from './initialize_edit_api';
import { extractReferences } from '../../common/migrations/references';
export function getControlledBy(id: string) {
return `mapEmbeddablePanel${id}`;
}
export const mapEmbeddableFactory: ReactEmbeddableFactory<MapSerializedState, MapApi> = {
type: MAP_SAVED_OBJECT_TYPE,
deserializeState: (state) => {
return state.rawState
? (inject(
state.rawState as EmbeddableStateWithType,
state.references ?? []
) as unknown as MapSerializedState)
: {};
},
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
const savedMap = new SavedMap({
mapSerializedState: state,
});
await savedMap.whenReady();
// eslint bug, eslint thinks api is never reassigned even though it is
// eslint-disable-next-line prefer-const
let api: MapApi | undefined;
const getApi = () => api;
const sharingSavedObjectProps = savedMap.getSharingSavedObjectProps();
const spaces = getSpacesApi();
const controlledBy = getControlledBy(uuid);
const title = initializeTitles(state);
const timeRange = initializeTimeRange(state);
const dynamicActionsApi = getEmbeddableEnhanced()?.initializeReactEmbeddableDynamicActions(
uuid,
() => title.titlesApi.panelTitle.getValue(),
state
);
const maybeStopDynamicActions = dynamicActionsApi?.startDynamicActions();
const defaultPanelTitle$ = new BehaviorSubject<string | undefined>(
savedMap.getAttributes().title
);
const defaultPanelDescription$ = new BehaviorSubject<string | undefined>(
savedMap.getAttributes().description
);
const reduxSync = initializeReduxSync({
savedMap,
state,
syncColors$: apiPublishesSettings(parentApi) ? parentApi.settings.syncColors$ : undefined,
uuid,
});
const actionHandlers = initializeActionHandlers(getApi);
const crossPanelActions = initializeCrossPanelActions({
controlledBy,
getActionContext: actionHandlers.getActionContext,
getApi,
state,
savedMap,
uuid,
});
function getState() {
return {
...state,
...timeRange.serialize(),
...title.serializeTitles(),
...(dynamicActionsApi?.serializeDynamicActions() ?? {}),
...crossPanelActions.serialize(),
...reduxSync.serialize(),
};
}
function serializeState() {
const rawState = getState();
// by-reference embeddable
if (rawState.savedObjectId) {
// No references to extract for by-reference embeddable since all references are stored with by-reference saved object
return {
rawState: getByReferenceState(rawState, rawState.savedObjectId),
references: [],
};
}
// by-value embeddable
const { attributes, references } = extractReferences({
attributes: savedMap.getAttributes(),
});
return {
rawState: getByValueState(rawState, attributes),
references,
};
}
api = buildApi(
{
defaultPanelTitle: defaultPanelTitle$,
defaultPanelDescription: defaultPanelDescription$,
...timeRange.api,
...(dynamicActionsApi?.dynamicActionsApi ?? {}),
...title.titlesApi,
...reduxSync.api,
...initializeEditApi(uuid, getState, parentApi, state.savedObjectId),
...initializeLibraryTransforms(savedMap, serializeState),
...initializeDataViews(savedMap.getStore()),
serializeState,
supportedTriggers: () => {
return [APPLY_FILTER_TRIGGER, VALUE_CLICK_TRIGGER];
},
},
{
...timeRange.comparators,
...title.titleComparators,
...(dynamicActionsApi?.dynamicActionsComparator ?? {
enhancements: getUnchangingComparator(),
}),
...crossPanelActions.comparators,
...reduxSync.comparators,
// readonly comparators
attributes: getUnchangingComparator(),
mapBuffer: getUnchangingComparator(),
savedObjectId: getUnchangingComparator(),
mapSettings: getUnchangingComparator(),
hideFilterActions: getUnchangingComparator(),
isSharable: getUnchangingComparator(),
tooltipRenderer: getUnchangingComparator(),
}
);
const unsubscribeFromFetch = initializeFetch({
api,
controlledBy,
getIsFilterByMapExtent: crossPanelActions.getIsFilterByMapExtent,
searchSessionMapBuffer: state.mapBuffer,
store: savedMap.getStore(),
});
return {
api,
Component: () => {
const [defaultPanelTitle, panelTitle, defaultPanelDescription, panelDescription] =
useBatchedPublishingSubjects(
defaultPanelTitle$,
title.titlesApi.panelTitle,
defaultPanelDescription$,
title.titlesApi.panelDescription
);
useEffect(() => {
return () => {
crossPanelActions.cleanup();
reduxSync.cleanup();
unsubscribeFromFetch();
maybeStopDynamicActions?.stopDynamicActions();
};
}, []);
return sharingSavedObjectProps &&
spaces &&
sharingSavedObjectProps?.outcome === 'conflict' ? (
<div className="mapEmbeddedError">
<EuiEmptyPrompt
iconType="warning"
iconColor="danger"
data-test-subj="embeddable-maps-failure"
body={spaces.ui.components.getEmbeddableLegacyUrlConflict({
targetType: MAP_SAVED_OBJECT_TYPE,
sourceId: sharingSavedObjectProps.sourceId!,
})}
/>
</div>
) : (
<Provider store={savedMap.getStore()}>
<MapContainer
onSingleValueTrigger={actionHandlers.onSingleValueTrigger}
addFilters={
state.hideFilterActions || areTriggersDisabled(api)
? null
: actionHandlers.addFilters
}
getFilterActions={actionHandlers.getFilterActions}
getActionContext={actionHandlers.getActionContext}
renderTooltipContent={state.tooltipRenderer}
title={panelTitle ?? defaultPanelTitle}
description={panelDescription ?? defaultPanelDescription}
waitUntilTimeLayersLoad$={waitUntilTimeLayersLoad$(savedMap.getStore())}
isSharable={state.isSharable ?? true}
/>
</Provider>
);
},
};
},
};

View file

@ -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, { useEffect, useRef } from 'react';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import { useSearchApi } from '@kbn/presentation-publishing';
import type { LayerDescriptor, MapCenterAndZoom, MapSettings } from '../../common/descriptor_types';
import { createBasemapLayerDescriptor } from '../classes/layers/create_basemap_layer_descriptor';
import { MapApi, MapSerializedState } from './types';
import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants';
import { RenderToolTipContent } from '../classes/tooltips/tooltip_property';
function getLayers(layerList: LayerDescriptor[]) {
const basemapLayer = createBasemapLayerDescriptor();
return basemapLayer ? [basemapLayer, ...layerList] : layerList;
}
export interface Props {
title?: string;
filters?: Filter[];
query?: Query;
timeRange?: TimeRange;
layerList: LayerDescriptor[];
mapSettings?: Partial<MapSettings>;
hideFilterActions?: boolean;
isLayerTOCOpen?: boolean;
mapCenter?: MapCenterAndZoom;
getTooltipRenderer?: () => RenderToolTipContent;
onApiAvailable?: (api: MapApi) => void;
/*
* Set to false to exclude sharing attributes 'data-*'.
*/
isSharable?: boolean;
}
export function MapRenderer(props: Props) {
const mapApiRef = useRef<MapApi | undefined>(undefined);
const beforeApiReadyLayerListRef = useRef<LayerDescriptor[] | undefined>(undefined);
useEffect(() => {
if (mapApiRef.current) {
mapApiRef.current.setLayerList(getLayers(props.layerList));
} else {
beforeApiReadyLayerListRef.current = getLayers(props.layerList);
}
}, [props.layerList]);
const searchApi = useSearchApi({
filters: props.filters,
query: props.query,
timeRange: props.timeRange,
});
return (
<div className="mapEmbeddableContainer">
<ReactEmbeddableRenderer<MapSerializedState, MapApi>
type={MAP_SAVED_OBJECT_TYPE}
getParentApi={() => ({
...searchApi,
getSerializedStateForChild: () => {
const rawState: MapSerializedState = {
attributes: {
title: props.title ?? '',
layerListJSON: JSON.stringify(getLayers(props.layerList)),
},
hidePanelTitles: !Boolean(props.title),
isLayerTOCOpen:
typeof props.isLayerTOCOpen === 'boolean' ? props.isLayerTOCOpen : false,
hideFilterActions:
typeof props.hideFilterActions === 'boolean' ? props.hideFilterActions : false,
mapCenter: props.mapCenter,
mapSettings: props.mapSettings ?? {},
isSharable: props.isSharable,
};
if (props.getTooltipRenderer) {
rawState.tooltipRenderer = props.getTooltipRenderer();
}
return {
rawState,
references: [],
};
},
})}
onApiAvailable={(api) => {
mapApiRef.current = api;
if (beforeApiReadyLayerListRef.current) {
api.setLayerList(beforeApiReadyLayerListRef.current);
}
if (props.onApiAvailable) {
props.onApiAvailable(api);
}
}}
hidePanelChrome={true}
/>
</div>
);
}

View file

@ -7,15 +7,15 @@
import React from 'react';
import { dynamic } from '@kbn/shared-ux-utility';
import type { Props } from './map_component';
import type { Props } from './map_renderer';
const Component = dynamic(async () => {
const { MapComponent } = await import('./map_component');
const { MapRenderer } = await import('./map_renderer');
return {
default: MapComponent,
default: MapRenderer,
};
});
export function MapComponentLazy(props: Props) {
export function MapRendererLazy(props: Props) {
return <Component {...props} />;
}

View file

@ -0,0 +1,39 @@
/*
* 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 { EmbeddableSetup } from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { MapAttributes } from '../../common/content_management';
import { MAP_SAVED_OBJECT_TYPE, APP_ICON } from '../../common/constants';
import { untilPluginStartServicesReady } from '../kibana_services';
export function setupMapEmbeddable(embeddableSetup: EmbeddableSetup) {
embeddableSetup.registerReactEmbeddableFactory(MAP_SAVED_OBJECT_TYPE, async () => {
const startServicesPromise = untilPluginStartServicesReady();
const [, { mapEmbeddableFactory }] = await Promise.all([
startServicesPromise,
import('./map_react_embeddable'),
]);
return mapEmbeddableFactory;
});
embeddableSetup.registerReactEmbeddableSavedObject<MapAttributes>({
onAdd: (container, savedObject) => {
container.addNewPanel({
panelType: MAP_SAVED_OBJECT_TYPE,
initialState: { savedObjectId: savedObject.id },
});
},
embeddableType: MAP_SAVED_OBJECT_TYPE,
savedObjectType: MAP_SAVED_OBJECT_TYPE,
savedObjectName: i18n.translate('xpack.maps.mapSavedObjectLabel', {
defaultMessage: 'Map',
}),
getIconForSavedObject: () => APP_ICON,
});
}

View file

@ -0,0 +1,86 @@
/*
* 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 { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
import { TimeRange } from '@kbn/es-query';
import { HasInspectorAdapters } from '@kbn/inspector-plugin/public';
import {
apiIsOfType,
apiPublishesPanelTitle,
apiPublishesUnifiedSearch,
HasEditCapabilities,
HasLibraryTransforms,
HasSupportedTriggers,
PublishesDataLoading,
PublishesDataViews,
PublishesUnifiedSearch,
SerializedTitles,
} from '@kbn/presentation-publishing';
import { HasDynamicActions } from '@kbn/embeddable-enhanced-plugin/public';
import { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public/plugin';
import { Observable } from 'rxjs';
import { MapAttributes } from '../../common/content_management';
import {
LayerDescriptor,
MapCenterAndZoom,
MapExtent,
MapSettings,
} from '../../common/descriptor_types';
import { ILayer } from '../classes/layers/layer';
import { RenderToolTipContent } from '../classes/tooltips/tooltip_property';
import { EventHandlers } from '../reducers/non_serializable_instances';
export type MapSerializedState = SerializedTitles &
Partial<DynamicActionsSerializedState> & {
// by-valye
attributes?: MapAttributes;
// by-reference
savedObjectId?: string;
isLayerTOCOpen?: boolean;
openTOCDetails?: string[];
mapCenter?: MapCenterAndZoom;
mapBuffer?: MapExtent;
mapSettings?: Partial<MapSettings>;
hiddenLayers?: string[];
hideFilterActions?: boolean;
timeRange?: TimeRange;
filterByMapExtent?: boolean;
isMovementSynchronized?: boolean;
// Configuration item that are never persisted
// Putting in state as a temporary work around
isSharable?: boolean;
tooltipRenderer?: RenderToolTipContent;
};
export type MapApi = DefaultEmbeddableApi<MapSerializedState> &
HasDynamicActions &
Partial<HasEditCapabilities> &
HasInspectorAdapters &
HasSupportedTriggers &
PublishesDataLoading &
PublishesDataViews &
PublishesUnifiedSearch &
HasLibraryTransforms<MapSerializedState> & {
getLayerList: () => ILayer[];
reload: () => void;
setEventHandlers: (eventHandlers: EventHandlers) => void;
setLayerList: (layerList: LayerDescriptor[]) => void;
updateLayerById: (layerDescriptor: LayerDescriptor) => void;
onRenderComplete$: Observable<void>;
};
export const isMapApi = (api: unknown): api is MapApi => {
return Boolean(
api &&
apiIsOfType(api, 'map') &&
typeof (api as MapApi).getLayerList === 'function' &&
apiPublishesPanelTitle(api) &&
apiPublishesUnifiedSearch(api)
);
};

View file

@ -27,9 +27,9 @@ import {
getCore,
} from './kibana_services';
import { ListPage, MapPage } from './routes';
import { MapByValueInput, MapByReferenceInput } from './embeddable/types';
import { APP_ID } from '../common/constants';
import { registerLayerWizards } from './classes/layers/wizards/load_layer_wizards';
import { MapSerializedState } from './react_embeddable/types';
function setAppChrome() {
if (!getMapsCapabilities().save) {
@ -83,19 +83,19 @@ export async function renderApp(
const { embeddableId, originatingApp, valueInput, originatingPath } =
stateTransfer.getIncomingEditorState(APP_ID) || {};
let mapEmbeddableInput;
let mapSerializedState: MapSerializedState | undefined;
if (routeProps.match.params.savedMapId) {
mapEmbeddableInput = {
mapSerializedState = {
savedObjectId: routeProps.match.params.savedMapId,
} as MapByReferenceInput;
};
} else if (valueInput) {
mapEmbeddableInput = valueInput as MapByValueInput;
mapSerializedState = valueInput as MapSerializedState;
}
return (
<ExitFullScreenButtonKibanaProvider coreStart={getCore()}>
<MapPage
mapEmbeddableInput={mapEmbeddableInput}
mapSerializedState={mapSerializedState}
embeddableId={embeddableId}
onAppLeave={onAppLeave}
setHeaderActionMenu={setHeaderActionMenu}

View file

@ -15,10 +15,10 @@ import {
getInitialLayersFromUrlParam,
getOpenLayerWizardFromUrlParam,
} from './saved_map';
import { MapEmbeddableInput } from '../../embeddable/types';
import type { MapSerializedState } from '../../react_embeddable/types';
interface Props {
mapEmbeddableInput?: MapEmbeddableInput;
mapSerializedState?: MapSerializedState;
embeddableId?: string;
onAppLeave: AppMountParameters['onAppLeave'];
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
@ -45,7 +45,7 @@ export class MapPage extends Component<Props, State> {
this.state = {
savedMap: new SavedMap({
defaultLayers: getInitialLayersFromUrlParam(),
mapEmbeddableInput: props.mapEmbeddableInput,
mapSerializedState: props.mapSerializedState,
embeddableId: props.embeddableId,
originatingApp: props.originatingApp,
originatingPath: props.originatingPath,

View file

@ -0,0 +1,48 @@
/*
* 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 { ResolvedSimpleSavedObject, SavedObjectReference } from '@kbn/core/public';
import type { MapAttributes } from '../../../../common/content_management';
import { getMapClient } from '../../../content_management';
import { injectReferences } from '../../../../common/migrations/references';
export interface SharingSavedObjectProps {
outcome?: ResolvedSimpleSavedObject['outcome'];
aliasTargetId?: ResolvedSimpleSavedObject['alias_target_id'];
aliasPurpose?: ResolvedSimpleSavedObject['alias_purpose'];
sourceId?: string;
}
export async function loadFromLibrary(savedObjectId: string): Promise<{
attributes: MapAttributes;
sharingSavedObjectProps: SharingSavedObjectProps;
managed: boolean;
references?: SavedObjectReference[];
}> {
const {
item: savedObject,
meta: { outcome, aliasPurpose, aliasTargetId },
} = await getMapClient<MapAttributes>().get(savedObjectId);
if (savedObject.error) {
throw savedObject.error;
}
const { attributes } = injectReferences(savedObject);
return {
attributes,
sharingSavedObjectProps: {
aliasTargetId,
outcome,
aliasPurpose,
sourceId: savedObjectId,
},
managed: Boolean(savedObject.managed),
references: savedObject.references,
};
}

View file

@ -0,0 +1,27 @@
/*
* 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 { SavedObjectReference } from '@kbn/core/public';
import type { MapAttributes } from '../../../../common/content_management';
import { getMapClient } from '../../../content_management';
export async function saveToLibrary(
attributes: MapAttributes,
references: SavedObjectReference[],
savedObjectId?: string
) {
const {
item: { id },
} = await (savedObjectId
? getMapClient().update({
id: savedObjectId,
data: attributes,
options: { references },
})
: getMapClient().create({ data: attributes, options: { references } }));
return { id };
}

View file

@ -35,8 +35,9 @@ import {
setHiddenLayers,
} from '../../../actions';
import { getIsLayerTOCOpen, getOpenTOCDetails } from '../../../selectors/ui_selectors';
import { getMapAttributeService, SharingSavedObjectProps } from '../../../map_attribute_service';
import { MapByReferenceInput, MapEmbeddableInput } from '../../../embeddable/types';
import { loadFromLibrary, SharingSavedObjectProps } from './load_from_library';
import { saveToLibrary } from './save_to_library';
import { MapSerializedState } from '../../../react_embeddable/types';
import {
getCoreChrome,
getIndexPatternService,
@ -56,6 +57,8 @@ import { ParsedMapStateJSON, ParsedUiStateJSON } from './types';
import { setAutoOpenLayerWizardId } from '../../../actions/ui_actions';
import { LayerStatsCollector, MapSettingsCollector } from '../../../../common/telemetry';
import { getIndexPatternsFromIds } from '../../../index_pattern_util';
import { extractReferences } from '../../../../common/migrations/references';
import { getByReferenceState, getByValueState } from '../../../react_embeddable/library_transforms';
function setMapSettingsFromEncodedState(settings: Partial<MapSettings>) {
const decodedCustomIcons = settings.customIcons
@ -76,7 +79,7 @@ export class SavedMap {
private readonly _defaultLayers: LayerDescriptor[];
private readonly _embeddableId?: string;
private _initialLayerListConfig: LayerDescriptor[] = [];
private _mapEmbeddableInput?: MapEmbeddableInput;
private _mapSerializedState?: MapSerializedState;
private readonly _onSaveCallback?: () => void;
private _originatingApp?: string;
private _originatingPath?: string;
@ -88,7 +91,7 @@ export class SavedMap {
constructor({
defaultLayers = [],
mapEmbeddableInput,
mapSerializedState,
embeddableId,
onSaveCallback,
originatingApp,
@ -97,7 +100,7 @@ export class SavedMap {
defaultLayerWizard,
}: {
defaultLayers?: LayerDescriptor[];
mapEmbeddableInput?: MapEmbeddableInput;
mapSerializedState?: MapSerializedState;
embeddableId?: string;
onSaveCallback?: () => void;
originatingApp?: string;
@ -106,7 +109,7 @@ export class SavedMap {
defaultLayerWizard?: string;
}) {
this._defaultLayers = defaultLayers;
this._mapEmbeddableInput = mapEmbeddableInput;
this._mapSerializedState = mapSerializedState;
this._embeddableId = embeddableId;
this._onSaveCallback = onSaveCallback;
this._originatingApp = originatingApp;
@ -124,25 +127,25 @@ export class SavedMap {
async whenReady() {
await whenLicenseInitialized();
if (!this._mapEmbeddableInput) {
this._attributes = {
title: '',
description: '',
};
} else {
const { attributes: doc, metaInfo } = await getMapAttributeService().unwrapAttributes(
this._mapEmbeddableInput
if (this._mapSerializedState?.savedObjectId) {
const { attributes, managed, references, sharingSavedObjectProps } = await loadFromLibrary(
this._mapSerializedState.savedObjectId
);
const { references, ...savedObjectAttributes } = doc;
this._attributes = savedObjectAttributes;
if (metaInfo?.sharingSavedObjectProps) {
this._sharingSavedObjectProps = metaInfo.sharingSavedObjectProps;
this._attributes = attributes;
if (sharingSavedObjectProps) {
this._sharingSavedObjectProps = sharingSavedObjectProps;
}
this._managed = Boolean(metaInfo?.managed);
this._managed = managed;
const savedObjectsTagging = getSavedObjectsTagging();
if (savedObjectsTagging && references && references.length) {
this._tags = savedObjectsTagging.ui.getTagIdsFromReferences(references);
}
} else {
this._attributes = this._mapSerializedState?.attributes
? this._mapSerializedState.attributes
: {
title: '',
};
}
this._reportUsage();
@ -162,8 +165,8 @@ export class SavedMap {
}
}
if (this._mapEmbeddableInput && this._mapEmbeddableInput.mapSettings !== undefined) {
this._store.dispatch(setMapSettingsFromEncodedState(this._mapEmbeddableInput.mapSettings));
if (this._mapSerializedState?.mapSettings !== undefined) {
this._store.dispatch(setMapSettingsFromEncodedState(this._mapSerializedState.mapSettings));
} else if (this._attributes?.mapStateJSON) {
try {
const mapState = JSON.parse(this._attributes.mapStateJSON) as ParsedMapStateJSON;
@ -176,8 +179,8 @@ export class SavedMap {
}
let isLayerTOCOpen = DEFAULT_IS_LAYER_TOC_OPEN;
if (this._mapEmbeddableInput && this._mapEmbeddableInput.isLayerTOCOpen !== undefined) {
isLayerTOCOpen = this._mapEmbeddableInput.isLayerTOCOpen;
if (this._mapSerializedState?.isLayerTOCOpen !== undefined) {
isLayerTOCOpen = this._mapSerializedState.isLayerTOCOpen;
} else if (this._attributes?.uiStateJSON) {
try {
const uiState = JSON.parse(this._attributes.uiStateJSON) as ParsedUiStateJSON;
@ -191,8 +194,8 @@ export class SavedMap {
this._store.dispatch(setIsLayerTOCOpen(isLayerTOCOpen));
let openTOCDetails: string[] = [];
if (this._mapEmbeddableInput && this._mapEmbeddableInput.openTOCDetails !== undefined) {
openTOCDetails = this._mapEmbeddableInput.openTOCDetails;
if (this._mapSerializedState?.openTOCDetails !== undefined) {
openTOCDetails = this._mapSerializedState.openTOCDetails;
} else if (this._attributes?.uiStateJSON) {
try {
const uiState = JSON.parse(this._attributes.uiStateJSON) as ParsedUiStateJSON;
@ -205,12 +208,12 @@ export class SavedMap {
}
this._store.dispatch(setOpenTOCDetails(openTOCDetails));
if (this._mapEmbeddableInput && this._mapEmbeddableInput.mapCenter !== undefined) {
if (this._mapSerializedState?.mapCenter !== undefined) {
this._store.dispatch(
setGotoWithCenter({
lat: this._mapEmbeddableInput.mapCenter.lat,
lon: this._mapEmbeddableInput.mapCenter.lon,
zoom: this._mapEmbeddableInput.mapCenter.zoom,
lat: this._mapSerializedState.mapCenter.lat,
lon: this._mapSerializedState.mapCenter.lon,
zoom: this._mapSerializedState.mapCenter.zoom,
})
);
} else if (this._attributes?.mapStateJSON) {
@ -245,8 +248,8 @@ export class SavedMap {
}
}
this._store.dispatch<any>(replaceLayerList(layerList));
if (this._mapEmbeddableInput && this._mapEmbeddableInput.hiddenLayers !== undefined) {
this._store.dispatch<any>(setHiddenLayers(this._mapEmbeddableInput.hiddenLayers));
if (this._mapSerializedState?.hiddenLayers !== undefined) {
this._store.dispatch<any>(setHiddenLayers(this._mapSerializedState.hiddenLayers));
}
this._initialLayerListConfig = copyPersistentState(layerList);
@ -283,7 +286,7 @@ export class SavedMap {
}
private _getPageTitle(): string {
if (!this._mapEmbeddableInput) {
if (!this._mapSerializedState) {
return i18n.translate('xpack.maps.breadcrumbsCreate', {
defaultMessage: 'Create',
});
@ -354,8 +357,8 @@ export class SavedMap {
}
public getSavedObjectId(): string | undefined {
return this._mapEmbeddableInput && 'savedObjectId' in this._mapEmbeddableInput
? (this._mapEmbeddableInput as MapByReferenceInput).savedObjectId
return this._mapSerializedState?.savedObjectId
? this._mapSerializedState.savedObjectId
: undefined;
}
@ -404,11 +407,8 @@ export class SavedMap {
}
public getAutoFitToBounds(): boolean {
if (
this._mapEmbeddableInput &&
this._mapEmbeddableInput?.mapSettings?.autoFitToDataBounds !== undefined
) {
return this._mapEmbeddableInput.mapSettings.autoFitToDataBounds;
if (this._mapSerializedState?.mapSettings?.autoFitToDataBounds !== undefined) {
return this._mapSerializedState.mapSettings.autoFitToDataBounds;
}
if (!this._attributes || !this._attributes.mapStateJSON) {
@ -445,13 +445,13 @@ export class SavedMap {
newTitle,
newCopyOnSave,
returnToOrigin,
newTags,
tags,
saveByReference,
dashboardId,
history,
}: OnSaveProps & {
returnToOrigin?: boolean;
newTags?: string[];
tags?: string[];
saveByReference: boolean;
dashboardId?: string | null;
history: ScopedHistory;
@ -462,36 +462,44 @@ export class SavedMap {
const prevTitle = this._attributes.title;
const prevDescription = this._attributes.description;
const prevTags = this._tags;
this._attributes.title = newTitle;
this._attributes.description = newDescription;
if (newTags) {
this._tags = newTags;
}
await this._syncAttributesWithStore();
let updatedMapEmbeddableInput: MapEmbeddableInput;
try {
const savedObjectsTagging = getSavedObjectsTagging();
// Attribute service deviates from Saved Object client by including references as a child to attributes in stead of a sibling
const attributes =
savedObjectsTagging && newTags
? {
...this._attributes,
references: savedObjectsTagging.ui.updateTagsReferences([], newTags),
}
: this._attributes;
updatedMapEmbeddableInput = (await getMapAttributeService().wrapAttributes(
attributes,
saveByReference,
newCopyOnSave ? undefined : this._mapEmbeddableInput
)) as MapEmbeddableInput;
} catch (e) {
// Error toast displayed by wrapAttributes
this._attributes.title = prevTitle;
this._attributes.description = prevDescription;
this._tags = prevTags;
return;
let mapSerializedState: MapSerializedState | undefined;
if (saveByReference) {
try {
const { attributes, references } = extractReferences({
attributes: this._attributes,
});
const savedObjectsTagging = getSavedObjectsTagging();
const tagReferences =
savedObjectsTagging && tags ? savedObjectsTagging.ui.updateTagsReferences([], tags) : [];
const { id: savedObjectId } = await saveToLibrary(
attributes,
[...references, ...tagReferences],
newCopyOnSave ? undefined : this._mapSerializedState?.savedObjectId
);
mapSerializedState = getByReferenceState(this._mapSerializedState, savedObjectId);
} catch (e) {
this._attributes.title = prevTitle;
this._attributes.description = prevDescription;
getToasts().addDanger({
title: i18n.translate('xpack.maps.saveToLibraryError', {
defaultMessage: `An error occurred while saving. Error: {errorMessage}`,
values: {
errorMessage: e.message,
},
}),
});
return;
}
} else {
mapSerializedState = getByValueState(this._mapSerializedState, this._attributes);
}
if (tags) {
this._tags = tags;
}
if (returnToOrigin) {
@ -511,7 +519,7 @@ export class SavedMap {
state: {
embeddableId: newCopyOnSave ? undefined : this._embeddableId,
type: MAP_SAVED_OBJECT_TYPE,
input: updatedMapEmbeddableInput,
input: mapSerializedState,
},
path: this._originatingPath,
});
@ -520,14 +528,14 @@ export class SavedMap {
await this._getStateTransfer().navigateToWithEmbeddablePackage('dashboards', {
state: {
type: MAP_SAVED_OBJECT_TYPE,
input: updatedMapEmbeddableInput,
input: mapSerializedState,
},
path: dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`,
});
return;
}
this._mapEmbeddableInput = updatedMapEmbeddableInput;
this._mapSerializedState = mapSerializedState;
// break connection to originating application
this._originatingApp = undefined;

View file

@ -150,15 +150,15 @@ export function getTopNavConfig({
}
},
run: () => {
let selectedTags = savedMap.getTags();
function onTagsSelected(newTags: string[]) {
selectedTags = newTags;
let tags = savedMap.getTags();
function onTagsSelected(nextTags: string[]) {
tags = nextTags;
}
const savedObjectsTagging = getSavedObjectsTagging();
const tagSelector = savedObjectsTagging ? (
<savedObjectsTagging.ui.components.SavedObjectSaveModalTagSelector
initialSelection={selectedTags}
initialSelection={tags}
onTagsSelected={onTagsSelected}
markOptional
/>
@ -193,7 +193,7 @@ export function getTopNavConfig({
await savedMap.save({
...props,
newTags: selectedTags,
tags,
saveByReference: props.addToLibrary,
history,
});

View file

@ -373,10 +373,6 @@ export function getLayerById(layerId: string | null, state: MapStoreState): ILay
});
}
export const getHiddenLayerIds = createSelector(getLayerListRaw, (layers) =>
layers.filter((layer) => !layer.visible).map((layer) => layer.id)
);
export const getSelectedLayer = createSelector(
getSelectedLayerId,
getLayerList,

View file

@ -15,7 +15,7 @@ import {
EuiSwitchEvent,
} from '@elastic/eui';
import { createReactOverlays } from '@kbn/kibana-react-plugin/public';
import { mapEmbeddablesSingleton } from '../../embeddable/map_embeddables_singleton';
import { mapEmbeddablesSingleton } from '../../react_embeddable/map_embeddables_singleton';
import { getCore } from '../../kibana_services';
export function openModal(title: string) {

View file

@ -10,7 +10,7 @@ import { apiHasVisualizeConfig } from '@kbn/visualizations-plugin/public';
import { isLensApi } from '@kbn/lens-plugin/public';
import { MAP_SAVED_OBJECT_TYPE } from '../../../common/constants';
import { isLegacyMapApi } from '../../legacy_visualizations/is_legacy_map';
import { mapEmbeddablesSingleton } from '../../embeddable/map_embeddables_singleton';
import { mapEmbeddablesSingleton } from '../../react_embeddable/map_embeddables_singleton';
import type { SynchronizeMovementActionApi } from './types';
export function isCompatible(api: SynchronizeMovementActionApi) {

View file

@ -16,7 +16,7 @@ import {
EuiSwitchEvent,
} from '@elastic/eui';
import { createReactOverlays } from '@kbn/kibana-react-plugin/public';
import { mapEmbeddablesSingleton } from '../../embeddable/map_embeddables_singleton';
import { mapEmbeddablesSingleton } from '../../react_embeddable/map_embeddables_singleton';
import { getCore } from '../../kibana_services';
export function openModal() {

View file

@ -84,11 +84,12 @@
"@kbn/code-editor",
"@kbn/managed-content-badge",
"@kbn/presentation-publishing",
"@kbn/saved-objects-finder-plugin",
"@kbn/esql-utils",
"@kbn/apm-data-view",
"@kbn/shared-ux-utility",
"@kbn/react-kibana-context-render",
"@kbn/embeddable-enhanced-plugin",
"@kbn/presentation-containers",
"@kbn/field-utils"
],
"exclude": [