diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 96318890a2d94..94ae72a050e21 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -53,6 +53,7 @@ export enum LAYER_TYPE { HEATMAP = 'HEATMAP', BLENDED_VECTOR = 'BLENDED_VECTOR', MVT_VECTOR = 'MVT_VECTOR', + LAYER_GROUP = 'LAYER_GROUP', } export enum SOURCE_TYPES { diff --git a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts index 160a34f8bcc0d..a547ef9c6d93a 100644 --- a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts @@ -71,6 +71,7 @@ export type LayerDescriptor = { style?: StyleDescriptor | null; query?: Query; includeInFitToBounds?: boolean; + parent?: string; }; export type VectorLayerDescriptor = LayerDescriptor & { @@ -89,3 +90,10 @@ export type EMSVectorTileLayerDescriptor = LayerDescriptor & { type: LAYER_TYPE.EMS_VECTOR_TILE; style: EMSVectorTileStyleDescriptor; }; + +export type LayerGroupDescriptor = LayerDescriptor & { + type: LAYER_TYPE.LAYER_GROUP; + label: string; + sourceDescriptor: null; + visible: boolean; +}; diff --git a/x-pack/plugins/maps/public/actions/data_request_actions.ts b/x-pack/plugins/maps/public/actions/data_request_actions.ts index b5ce42ebefc09..5e27b488065ab 100644 --- a/x-pack/plugins/maps/public/actions/data_request_actions.ts +++ b/x-pack/plugins/maps/public/actions/data_request_actions.ts @@ -9,9 +9,7 @@ import { AnyAction, Dispatch } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; -import bbox from '@turf/bbox'; import uuid from 'uuid/v4'; -import { multiPoint } from '@turf/helpers'; import { FeatureCollection } from 'geojson'; import { Adapters } from '@kbn/inspector-plugin/common/adapters'; import { MapStoreState } from '../reducers/store'; @@ -49,7 +47,9 @@ import { ILayer } from '../classes/layers/layer'; import { IVectorLayer } from '../classes/layers/vector_layer'; import { DataRequestMeta, MapExtent, DataFilters } from '../../common/descriptor_types'; import { DataRequestAbortError } from '../classes/util/data_request'; -import { scaleBounds, turfBboxToBounds } from '../../common/elasticsearch_util'; +import { scaleBounds } from '../../common/elasticsearch_util'; +import { getLayersExtent } from './get_layers_extent'; +import { isLayerGroup } from '../classes/layers/layer_group'; const FIT_TO_BOUNDS_SCALE_FACTOR = 0.1; @@ -101,7 +101,7 @@ export function cancelAllInFlightRequests() { export function updateStyleMeta(layerId: string | null) { return async (dispatch: Dispatch, getState: () => MapStoreState) => { const layer = getLayerById(layerId, getState()); - if (!layer) { + if (!layer || isLayerGroup(layer)) { return; } @@ -378,8 +378,8 @@ export function fitToLayerExtent(layerId: string) { if (targetLayer) { try { - const bounds = await targetLayer.getBounds( - getDataRequestContext(dispatch, getState, layerId, false, false) + const bounds = await targetLayer.getBounds((boundsLayerId) => + getDataRequestContext(dispatch, getState, boundsLayerId, false, false) ); if (bounds) { await dispatch(setGotoWithBounds(scaleBounds(bounds, FIT_TO_BOUNDS_SCALE_FACTOR))); @@ -401,65 +401,22 @@ export function fitToLayerExtent(layerId: string) { export function fitToDataBounds(onNoBounds?: () => void) { return async (dispatch: Dispatch, getState: () => MapStoreState) => { - const layerList = getLayerList(getState()); - - if (!layerList.length) { - return; - } - - const boundsPromises = layerList.map(async (layer: ILayer) => { - if (!(await layer.isFittable())) { - return null; - } - return layer.getBounds( - getDataRequestContext(dispatch, getState, layer.getId(), false, false) - ); + const rootLayers = getLayerList(getState()).filter((layer) => { + return layer.getParent() === undefined; }); - let bounds; - try { - bounds = await Promise.all(boundsPromises); - } catch (error) { - if (!(error instanceof DataRequestAbortError)) { - // eslint-disable-next-line no-console - console.warn( - 'Unhandled getBounds error for layer. Only DataRequestAbortError should be surfaced', - error - ); - } - // new fitToDataBounds request has superseded this thread of execution. Results no longer needed. - return; - } - - const corners = []; - for (let i = 0; i < bounds.length; i++) { - const b = bounds[i]; - - // filter out undefined bounds (uses Infinity due to turf responses) - if ( - b === null || - b.minLon === Infinity || - b.maxLon === Infinity || - b.minLat === -Infinity || - b.maxLat === -Infinity - ) { - continue; - } - - corners.push([b.minLon, b.minLat]); - corners.push([b.maxLon, b.maxLat]); - } + const extent = await getLayersExtent(rootLayers, (boundsLayerId) => + getDataRequestContext(dispatch, getState, boundsLayerId, false, false) + ); - if (!corners.length) { + if (extent === null) { if (onNoBounds) { onNoBounds(); } return; } - const dataBounds = turfBboxToBounds(bbox(multiPoint(corners))); - - dispatch(setGotoWithBounds(scaleBounds(dataBounds, FIT_TO_BOUNDS_SCALE_FACTOR))); + dispatch(setGotoWithBounds(scaleBounds(extent, FIT_TO_BOUNDS_SCALE_FACTOR))); }; } diff --git a/x-pack/plugins/maps/public/actions/get_layers_extent.tsx b/x-pack/plugins/maps/public/actions/get_layers_extent.tsx new file mode 100644 index 0000000000000..81d8367bd2803 --- /dev/null +++ b/x-pack/plugins/maps/public/actions/get_layers_extent.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import bbox from '@turf/bbox'; +import { multiPoint } from '@turf/helpers'; +import { MapExtent } from '../../common/descriptor_types'; +import { turfBboxToBounds } from '../../common/elasticsearch_util'; +import { ILayer } from '../classes/layers/layer'; +import type { DataRequestContext } from './data_request_actions'; +import { DataRequestAbortError } from '../classes/util/data_request'; + +export async function getLayersExtent( + layers: ILayer[], + getDataRequestContext: (layerId: string) => DataRequestContext +): Promise { + if (!layers.length) { + return null; + } + + const boundsPromises = layers.map(async (layer: ILayer) => { + if (!(await layer.isFittable())) { + return null; + } + return layer.getBounds(getDataRequestContext); + }); + + let bounds; + try { + bounds = await Promise.all(boundsPromises); + } catch (error) { + if (!(error instanceof DataRequestAbortError)) { + // eslint-disable-next-line no-console + console.warn( + 'Unhandled getBounds error for layer. Only DataRequestAbortError should be surfaced', + error + ); + } + // new fitToDataBounds request has superseded this thread of execution. Results no longer needed. + return null; + } + + const corners = []; + for (let i = 0; i < bounds.length; i++) { + const b = bounds[i]; + + // filter out undefined bounds (uses Infinity due to turf responses) + if ( + b === null || + b.minLon === Infinity || + b.maxLon === Infinity || + b.minLat === -Infinity || + b.maxLat === -Infinity + ) { + continue; + } + + corners.push([b.minLon, b.minLat]); + corners.push([b.maxLon, b.maxLat]); + } + + return corners.length ? turfBboxToBounds(bbox(multiPoint(corners))) : null; +} diff --git a/x-pack/plugins/maps/public/actions/index.ts b/x-pack/plugins/maps/public/actions/index.ts index 96db1cebe7d39..235f8d141411e 100644 --- a/x-pack/plugins/maps/public/actions/index.ts +++ b/x-pack/plugins/maps/public/actions/index.ts @@ -24,3 +24,4 @@ export { openOnHoverTooltip, updateOpenTooltips, } from './tooltip_actions'; +export { getLayersExtent } from './get_layers_extent'; diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index 317f6e09053e5..5ba9edaee58d0 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -75,6 +75,7 @@ import { IESAggField } from '../classes/fields/agg'; import { IField } from '../classes/fields/field'; import type { IESSource } from '../classes/sources/es_source'; import { getDrawMode, getOpenTOCDetails } from '../selectors/ui_selectors'; +import { isLayerGroup, LayerGroup } from '../classes/layers/layer_group'; export function trackCurrentLayerState(layerId: string) { return { @@ -160,8 +161,12 @@ export function cloneLayer(layerId: string) { return; } - const clonedDescriptor = await layer.cloneDescriptor(); - dispatch(addLayer(clonedDescriptor)); + (await layer.cloneDescriptor()).forEach((layerDescriptor) => { + dispatch(addLayer(layerDescriptor)); + if (layer.getParent()) { + dispatch(moveLayerToLeftOfTarget(layerDescriptor.id, layerId)); + } + }); }; } @@ -249,12 +254,19 @@ export function setLayerVisibility(layerId: string, makeVisible: boolean) { dispatch: ThunkDispatch, getState: () => MapStoreState ) => { - // if the current-state is invisible, we also want to sync data - // e.g. if a layer was invisible at start-up, it won't have any data loaded const layer = getLayerById(layerId, getState()); + if (!layer) { + return; + } + + if (isLayerGroup(layer)) { + (layer as LayerGroup).getChildren().forEach((childLayer) => { + dispatch(setLayerVisibility(childLayer.getId(), makeVisible)); + }); + } // If the layer visibility is already what we want it to be, do nothing - if (!layer || layer.isVisible() === makeVisible) { + if (layer.isVisible() === makeVisible) { return; } @@ -263,6 +275,9 @@ export function setLayerVisibility(layerId: string, makeVisible: boolean) { layerId, visibility: makeVisible, }); + + // if the current-state is invisible, we also want to sync data + // e.g. if a layer was invisible at start-up, it won't have any data loaded if (makeVisible) { dispatch(syncDataForLayerId(layerId, false)); } @@ -290,7 +305,7 @@ export function hideAllLayers() { getState: () => MapStoreState ) => { getLayerList(getState()).forEach((layer: ILayer, index: number) => { - if (layer.isVisible() && !layer.isBasemap(index)) { + if (!layer.isBasemap(index)) { dispatch(setLayerVisibility(layer.getId(), false)); } }); @@ -303,9 +318,7 @@ export function showAllLayers() { getState: () => MapStoreState ) => { getLayerList(getState()).forEach((layer: ILayer, index: number) => { - if (!layer.isVisible()) { - dispatch(setLayerVisibility(layer.getId(), true)); - } + dispatch(setLayerVisibility(layer.getId(), true)); }); }; } @@ -316,23 +329,20 @@ export function showThisLayerOnly(layerId: string) { getState: () => MapStoreState ) => { getLayerList(getState()).forEach((layer: ILayer, index: number) => { - if (layer.isBasemap(index)) { - return; - } - - // show target layer - if (layer.getId() === layerId) { - if (!layer.isVisible()) { - dispatch(setLayerVisibility(layerId, true)); - } + if (layer.isBasemap(index) || layer.getId() === layerId) { return; } // hide all other layers - if (layer.isVisible()) { - dispatch(setLayerVisibility(layer.getId(), false)); - } + dispatch(setLayerVisibility(layer.getId(), false)); }); + + // show target layer after hiding all other layers + // since hiding layer group will hide its children + const targetLayer = getLayerById(layerId, getState()); + if (targetLayer) { + dispatch(setLayerVisibility(layerId, true)); + } }; } @@ -602,6 +612,15 @@ export function setLayerQuery(id: string, query: Query) { }; } +export function setLayerParent(id: string, parent: string | undefined) { + return { + type: UPDATE_LAYER_PROP, + id, + propName: 'parent', + newValue: parent, + }; +} + export function removeSelectedLayer() { return ( dispatch: ThunkDispatch, @@ -657,6 +676,12 @@ function removeLayerFromLayerList(layerId: string) { if (openTOCDetails.includes(layerId)) { dispatch(hideTOCDetails(layerId)); } + + if (isLayerGroup(layerGettingRemoved)) { + (layerGettingRemoved as LayerGroup).getChildren().forEach((childLayer) => { + dispatch(removeLayerFromLayerList(childLayer.getId())); + }); + } }; } @@ -786,7 +811,7 @@ export function updateMetaFromTiles(layerId: string, mbMetaFeatures: TileMetaFea } function clearInspectorAdapters(layer: ILayer, adapters: Adapters) { - if (!layer.getSource().isESSource()) { + if (isLayerGroup(layer) || !layer.getSource().isESSource()) { return; } @@ -811,3 +836,93 @@ function hasByValueStyling(styleDescriptor: StyleDescriptor) { }) ); } + +export function createLayerGroup(draggedLayerId: string, combineLayerId: string) { + return ( + dispatch: ThunkDispatch, + getState: () => MapStoreState + ) => { + const group = LayerGroup.createDescriptor({}); + const combineLayerDescriptor = getLayerDescriptor(getState(), combineLayerId); + if (combineLayerDescriptor?.parent) { + group.parent = combineLayerDescriptor.parent; + } + dispatch({ + type: ADD_LAYER, + layer: group, + }); + // Move group to left of combine-layer + dispatch(moveLayerToLeftOfTarget(group.id, combineLayerId)); + + dispatch(showTOCDetails(group.id)); + dispatch(setLayerParent(draggedLayerId, group.id)); + dispatch(setLayerParent(combineLayerId, group.id)); + + // Move dragged-layer to left of combine-layer + dispatch(moveLayerToLeftOfTarget(draggedLayerId, combineLayerId)); + }; +} + +export function moveLayerToLeftOfTarget(moveLayerId: string, targetLayerId: string) { + return ( + dispatch: ThunkDispatch, + getState: () => MapStoreState + ) => { + const layers = getLayerList(getState()); + const moveLayerIndex = layers.findIndex((layer) => layer.getId() === moveLayerId); + const targetLayerIndex = layers.findIndex((layer) => layer.getId() === targetLayerId); + if (moveLayerIndex === -1 || targetLayerIndex === -1) { + return; + } + const moveLayer = layers[moveLayerIndex]; + + const newIndex = + moveLayerIndex > targetLayerIndex + ? // When layer is moved to the right, new left sibling index is to the left of destination + targetLayerIndex + 1 + : // When layer is moved to the left, new left sibling index is the destination index + targetLayerIndex; + const newOrder = []; + for (let i = 0; i < layers.length; i++) { + newOrder.push(i); + } + newOrder.splice(moveLayerIndex, 1); + newOrder.splice(newIndex, 0, moveLayerIndex); + dispatch(updateLayerOrder(newOrder)); + + if (isLayerGroup(moveLayer)) { + (moveLayer as LayerGroup).getChildren().forEach((childLayer) => { + dispatch(moveLayerToLeftOfTarget(childLayer.getId(), targetLayerId)); + }); + } + }; +} + +export function moveLayerToBottom(moveLayerId: string) { + return ( + dispatch: ThunkDispatch, + getState: () => MapStoreState + ) => { + const layers = getLayerList(getState()); + const moveLayerIndex = layers.findIndex((layer) => layer.getId() === moveLayerId); + if (moveLayerIndex === -1) { + return; + } + const moveLayer = layers[moveLayerIndex]; + + const newIndex = 0; + const newOrder = []; + for (let i = 0; i < layers.length; i++) { + newOrder.push(i); + } + newOrder.splice(moveLayerIndex, 1); + newOrder.splice(newIndex, 0, moveLayerIndex); + dispatch(updateLayerOrder(newOrder)); + + if (isLayerGroup(moveLayer)) { + (moveLayer as LayerGroup).getChildren().forEach((childLayer) => { + dispatch(moveLayerToBottom(childLayer.getId())); + }); + } + }; +} diff --git a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts index ec9cec3a914ba..bc013cb958a4f 100644 --- a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts @@ -200,10 +200,10 @@ export class HeatmapLayer extends AbstractLayer { return this.getCurrentStyle().renderLegendDetails(metricFields[0]); } - async getBounds(syncContext: DataRequestContext) { + async getBounds(getDataRequestContext: (layerId: string) => DataRequestContext) { return await syncBoundsData({ layerId: this.getId(), - syncContext, + syncContext: getDataRequestContext(this.getId()), source: this.getSource(), sourceQuery: this.getQuery(), }); diff --git a/x-pack/plugins/maps/public/classes/layers/layer.test.ts b/x-pack/plugins/maps/public/classes/layers/layer.test.ts index 194b41680872c..908c38a2eef27 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/layer.test.ts @@ -18,7 +18,7 @@ class MockSource { this._fitToBounds = fitToBounds; } cloneDescriptor() { - return {}; + return [{}]; } async supportsFitToBounds() { diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index b01f2b9b8ca04..ef1a72649bbf0 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -20,7 +20,6 @@ import { MAX_ZOOM, MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, MIN_ZOOM, - SOURCE_BOUNDS_DATA_REQUEST_ID, SOURCE_DATA_REQUEST_ID, } from '../../../common/constants'; import { copyPersistentState } from '../../reducers/copy_persistent_state'; @@ -41,7 +40,9 @@ import { LICENSED_FEATURES } from '../../licensed_features'; import { IESSource } from '../sources/es_source'; export interface ILayer { - getBounds(dataRequestContext: DataRequestContext): Promise; + getBounds( + getDataRequestContext: (layerId: string) => DataRequestContext + ): Promise; getDataRequest(id: string): DataRequest | undefined; getDisplayName(source?: ISource): Promise; getId(): string; @@ -68,7 +69,6 @@ export interface ILayer { getImmutableSourceProperties(): Promise; renderSourceSettingsEditor(sourceEditorArgs: SourceEditorArgs): ReactElement | null; isLayerLoading(): boolean; - isLoadingBounds(): boolean; isFilteredByGlobalTime(): Promise; hasErrors(): boolean; getErrors(): string; @@ -92,7 +92,7 @@ export interface ILayer { getQueryableIndexPatternIds(): string[]; getType(): LAYER_TYPE; isVisible(): boolean; - cloneDescriptor(): Promise; + cloneDescriptor(): Promise; renderStyleEditor( onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void, onCustomIconsChange: (customIcons: CustomIcon[]) => void @@ -117,6 +117,7 @@ export interface ILayer { getGeoFieldNames(): string[]; getStyleMetaDescriptorFromLocalFeatures(): Promise; isBasemap(order: number): boolean; + getParent(): string | undefined; } export type LayerIcon = { @@ -174,14 +175,14 @@ export class AbstractLayer implements ILayer { return this._descriptor; } - async cloneDescriptor(): Promise { + async cloneDescriptor(): Promise { const clonedDescriptor = copyPersistentState(this._descriptor); // layer id is uuid used to track styles/layers in mapbox clonedDescriptor.id = uuid(); const displayName = await this.getDisplayName(); clonedDescriptor.label = `Clone of ${displayName}`; clonedDescriptor.sourceDescriptor = this.getSource().cloneDescriptor(); - return clonedDescriptor; + return [clonedDescriptor]; } makeMbLayerId(layerNameSuffix: string): string { @@ -383,11 +384,6 @@ export class AbstractLayer implements ILayer { return areTilesLoading || this._dataRequests.some((dataRequest) => dataRequest.isLoading()); } - isLoadingBounds() { - const boundsDataRequest = this.getDataRequest(SOURCE_BOUNDS_DATA_REQUEST_ID); - return !!boundsDataRequest && boundsDataRequest.isLoading(); - } - hasErrors(): boolean { return _.get(this._descriptor, '__isInErrorState', false); } @@ -427,7 +423,9 @@ export class AbstractLayer implements ILayer { return sourceDataRequest ? sourceDataRequest.hasData() : false; } - async getBounds(dataRequestContext: DataRequestContext): Promise { + async getBounds( + getDataRequestContext: (layerId: string) => DataRequestContext + ): Promise { return null; } @@ -488,6 +486,10 @@ export class AbstractLayer implements ILayer { return false; } + getParent(): string | undefined { + return this._descriptor.parent; + } + _getMetaFromTiles(): TileMetaFeature[] { return this._descriptor.__metaFromTiles || []; } diff --git a/x-pack/plugins/maps/public/classes/layers/layer_group/index.ts b/x-pack/plugins/maps/public/classes/layers/layer_group/index.ts new file mode 100644 index 0000000000000..3b2848d03f5ff --- /dev/null +++ b/x-pack/plugins/maps/public/classes/layers/layer_group/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { isLayerGroup, LayerGroup } from './layer_group'; diff --git a/x-pack/plugins/maps/public/classes/layers/layer_group/layer_group.tsx b/x-pack/plugins/maps/public/classes/layers/layer_group/layer_group.tsx new file mode 100644 index 0000000000000..c0e3c4ee56402 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/layers/layer_group/layer_group.tsx @@ -0,0 +1,385 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { Map as MbMap } from '@kbn/mapbox-gl'; +import type { Query } from '@kbn/es-query'; +import { asyncMap } from '@kbn/std'; +import React, { ReactElement } from 'react'; +import { EuiIcon } from '@elastic/eui'; +import uuid from 'uuid/v4'; +import { LAYER_TYPE, MAX_ZOOM, MIN_ZOOM } from '../../../../common/constants'; +import { DataRequest } from '../../util/data_request'; +import { copyPersistentState } from '../../../reducers/copy_persistent_state'; +import { + Attribution, + CustomIcon, + LayerDescriptor, + LayerGroupDescriptor, + MapExtent, + StyleDescriptor, + StyleMetaDescriptor, +} from '../../../../common/descriptor_types'; +import { ImmutableSourceProperty, ISource, SourceEditorArgs } from '../../sources/source'; +import { type DataRequestContext } from '../../../actions'; +import { getLayersExtent } from '../../../actions/get_layers_extent'; +import { ILayer, LayerIcon } from '../layer'; +import { IStyle } from '../../styles/style'; +import { LICENSED_FEATURES } from '../../../licensed_features'; + +export function isLayerGroup(layer: ILayer) { + return layer instanceof LayerGroup; +} + +export class LayerGroup implements ILayer { + protected readonly _descriptor: LayerGroupDescriptor; + private _children: ILayer[] = []; + + static createDescriptor(options: Partial): LayerGroupDescriptor { + return { + ...options, + type: LAYER_TYPE.LAYER_GROUP, + id: typeof options.id === 'string' && options.id.length ? options.id : uuid(), + label: + typeof options.label === 'string' && options.label.length + ? options.label + : i18n.translate('xpack.maps.layerGroup.defaultName', { + defaultMessage: 'Layer group', + }), + sourceDescriptor: null, + visible: typeof options.visible === 'boolean' ? options.visible : true, + }; + } + + constructor({ layerDescriptor }: { layerDescriptor: LayerGroupDescriptor }) { + this._descriptor = LayerGroup.createDescriptor(layerDescriptor); + } + + setChildren(children: ILayer[]) { + this._children = children; + } + + getChildren(): ILayer[] { + return [...this._children]; + } + + async _asyncSomeChildren(methodName: string) { + const promises = this.getChildren().map(async (child) => { + // @ts-ignore + return (child[methodName] as () => Promise)(); + }); + return ((await Promise.all(promises)) as boolean[]).some((result) => { + return result; + }); + } + + getDescriptor(): LayerGroupDescriptor { + return this._descriptor; + } + + async cloneDescriptor(): Promise { + const clonedDescriptor = copyPersistentState(this._descriptor); + clonedDescriptor.id = uuid(); + const displayName = await this.getDisplayName(); + clonedDescriptor.label = `Clone of ${displayName}`; + + const childrenDescriptors = await asyncMap(this.getChildren(), async (childLayer) => { + return (await childLayer.cloneDescriptor()).map((childLayerDescriptor) => { + if (childLayerDescriptor.parent === this.getId()) { + childLayerDescriptor.parent = clonedDescriptor.id; + } + return childLayerDescriptor; + }); + }); + + return [..._.flatten(childrenDescriptors), clonedDescriptor]; + } + + makeMbLayerId(layerNameSuffix: string): string { + throw new Error( + 'makeMbLayerId should not be called on LayerGroup, LayerGroup does not render to map' + ); + } + + isPreviewLayer(): boolean { + return !!this._descriptor.__isPreviewLayer; + } + + supportsElasticsearchFilters(): boolean { + return this.getChildren().some((child) => { + return child.supportsElasticsearchFilters(); + }); + } + + async supportsFitToBounds(): Promise { + return this._asyncSomeChildren('supportsFitToBounds'); + } + + async isFittable(): Promise { + return this._asyncSomeChildren('isFittable'); + } + + isIncludeInFitToBounds(): boolean { + return this.getChildren().some((child) => { + return child.isIncludeInFitToBounds(); + }); + } + + async isFilteredByGlobalTime(): Promise { + return this._asyncSomeChildren('isFilteredByGlobalTime'); + } + + async getDisplayName(source?: ISource): Promise { + return this.getLabel(); + } + + async getAttributions(): Promise { + return []; + } + + getStyleForEditing(): IStyle { + throw new Error( + 'getStyleForEditing should not be called on LayerGroup, LayerGroup does not render to map' + ); + } + + getStyle(): IStyle { + throw new Error( + 'getStyle should not be called on LayerGroup, LayerGroup does not render to map' + ); + } + + getCurrentStyle(): IStyle { + throw new Error( + 'getCurrentStyle should not be called on LayerGroup, LayerGroup does not render to map' + ); + } + + getLabel(): string { + return this._descriptor.label ? this._descriptor.label : ''; + } + + getLocale(): string | null { + return null; + } + + getLayerIcon(isTocIcon: boolean): LayerIcon { + return { + icon: , + tooltipContent: '', + }; + } + + async hasLegendDetails(): Promise { + return this._children.length > 0; + } + + renderLegendDetails(): ReactElement | null { + return null; + } + + getId(): string { + return this._descriptor.id; + } + + getSource(): ISource { + throw new Error( + 'getSource should not be called on LayerGroup, LayerGroup does not render to map' + ); + } + + getSourceForEditing(): ISource { + throw new Error( + 'getSourceForEditing should not be called on LayerGroup, LayerGroup does not render to map' + ); + } + + isVisible(): boolean { + return !!this._descriptor.visible; + } + + showAtZoomLevel(zoom: number): boolean { + return zoom >= this.getMinZoom() && zoom <= this.getMaxZoom(); + } + + getMinZoom(): number { + let min = MIN_ZOOM; + this._children.forEach((child) => { + min = Math.max(min, child.getMinZoom()); + }); + return min; + } + + getMaxZoom(): number { + let max = MAX_ZOOM; + this._children.forEach((child) => { + max = Math.min(max, child.getMaxZoom()); + }); + return max; + } + + getMinSourceZoom(): number { + let min = MIN_ZOOM; + this._children.forEach((child) => { + min = Math.max(min, child.getMinSourceZoom()); + }); + return min; + } + + getMbSourceId(): string { + throw new Error( + 'getMbSourceId should not be called on LayerGroup, LayerGroup does not render to map' + ); + } + + getAlpha(): number { + throw new Error( + 'getAlpha should not be called on LayerGroup, LayerGroup does not render to map' + ); + } + + getQuery(): Query | null { + return null; + } + + async getImmutableSourceProperties(): Promise { + return []; + } + + renderSourceSettingsEditor(sourceEditorArgs: SourceEditorArgs) { + return null; + } + + getPrevRequestToken(dataId: string): symbol | undefined { + return undefined; + } + + getInFlightRequestTokens(): symbol[] { + return []; + } + + getSourceDataRequest(): DataRequest | undefined { + return undefined; + } + + getDataRequest(id: string): DataRequest | undefined { + return undefined; + } + + isLayerLoading(): boolean { + return this._children.some((child) => { + return child.isLayerLoading(); + }); + } + + hasErrors(): boolean { + return this._children.some((child) => { + return child.hasErrors(); + }); + } + + getErrors(): string { + const firstChildWithError = this._children.find((child) => { + return child.hasErrors(); + }); + return firstChildWithError ? firstChildWithError.getErrors() : ''; + } + + async syncData(syncContext: DataRequestContext) { + // layer group does not render to map so there is never sync data request + } + + getMbLayerIds(): string[] { + return []; + } + + ownsMbLayerId(layerId: string): boolean { + return false; + } + + ownsMbSourceId(mbSourceId: string): boolean { + return false; + } + + syncLayerWithMB(mbMap: MbMap) { + // layer group does not render to map so there is never sync data request + } + + getLayerTypeIconName(): string { + return 'layers'; + } + + isInitialDataLoadComplete(): boolean { + return true; + } + + async getBounds( + getDataRequestContext: (layerId: string) => DataRequestContext + ): Promise { + return getLayersExtent(this.getChildren(), getDataRequestContext); + } + + renderStyleEditor( + onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void, + onCustomIconsChange: (customIcons: CustomIcon[]) => void + ): ReactElement | null { + return null; + } + + getIndexPatternIds(): string[] { + return []; + } + + getQueryableIndexPatternIds(): string[] { + return []; + } + + syncVisibilityWithMb(mbMap: unknown, mbLayerId: string) { + throw new Error( + 'syncVisibilityWithMb should not be called on LayerGroup, LayerGroup does not render to map' + ); + } + + getType(): LAYER_TYPE { + return LAYER_TYPE.LAYER_GROUP; + } + + areLabelsOnTop(): boolean { + return false; + } + + supportsLabelsOnTop(): boolean { + return false; + } + + supportsLabelLocales(): boolean { + return false; + } + + async getLicensedFeatures(): Promise { + return []; + } + + getGeoFieldNames(): string[] { + return []; + } + + async getStyleMetaDescriptorFromLocalFeatures(): Promise { + throw new Error( + 'getStyleMetaDescriptorFromLocalFeatures should not be called on LayerGroup, LayerGroup does not render to map' + ); + } + + isBasemap(order: number): boolean { + return false; + } + + getParent(): string | undefined { + return this._descriptor.parent; + } +} diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.test.tsx index f2ef7ca9588be..03da177cddbd9 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.test.tsx @@ -141,7 +141,9 @@ describe('cloneDescriptor', () => { customIcons, }); - const clonedLayerDescriptor = await blendedVectorLayer.cloneDescriptor(); + const clones = await blendedVectorLayer.cloneDescriptor(); + expect(clones.length).toBe(1); + const clonedLayerDescriptor = clones[0]; expect(clonedLayerDescriptor.sourceDescriptor!.type).toBe(SOURCE_TYPES.ES_SEARCH); expect(clonedLayerDescriptor.label).toBe('Clone of myIndexPattern'); }); @@ -161,7 +163,9 @@ describe('cloneDescriptor', () => { customIcons, }); - const clonedLayerDescriptor = await blendedVectorLayer.cloneDescriptor(); + const clones = await blendedVectorLayer.cloneDescriptor(); + expect(clones.length).toBe(1); + const clonedLayerDescriptor = clones[0]; expect(clonedLayerDescriptor.sourceDescriptor!.type).toBe(SOURCE_TYPES.ES_SEARCH); expect(clonedLayerDescriptor.label).toBe('Clone of myIndexPattern'); }); diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts index a4b06fe043ff2..ee9fdaf410abb 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts @@ -250,8 +250,12 @@ export class BlendedVectorLayer extends GeoJsonVectorLayer implements IVectorLay return false; } - async cloneDescriptor(): Promise { - const clonedDescriptor = await super.cloneDescriptor(); + async cloneDescriptor(): Promise { + const clones = await super.cloneDescriptor(); + if (clones.length === 0) { + return []; + } + const clonedDescriptor = clones[0]; // Use super getDisplayName instead of instance getDisplayName to avoid getting 'Clustered Clone of Clustered' const displayName = await super.getDisplayName(); @@ -260,7 +264,7 @@ export class BlendedVectorLayer extends GeoJsonVectorLayer implements IVectorLay // sourceDescriptor must be document source descriptor clonedDescriptor.sourceDescriptor = this._documentSource.cloneDescriptor(); - return clonedDescriptor; + return [clonedDescriptor]; } getSource(): IVectorSource { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_vector_layer.tsx index bc7ba78c84d98..14a5092606b4d 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/geojson_vector_layer.tsx @@ -15,6 +15,7 @@ import { EMPTY_FEATURE_COLLECTION, FEATURE_VISIBLE_PROPERTY_NAME, LAYER_TYPE, + SOURCE_BOUNDS_DATA_REQUEST_ID, } from '../../../../../common/constants'; import { StyleMetaDescriptor, @@ -59,11 +60,11 @@ export class GeoJsonVectorLayer extends AbstractVectorLayer { return layerDescriptor; } - async getBounds(syncContext: DataRequestContext) { + async getBounds(getDataRequestContext: (layerId: string) => DataRequestContext) { const isStaticLayer = !this.getSource().isBoundsAware(); return isStaticLayer || this.hasJoins() ? getFeatureCollectionBounds(this._getSourceFeatureCollection(), this.hasJoins()) - : super.getBounds(syncContext); + : super.getBounds(getDataRequestContext); } getLayerIcon(isTocIcon: boolean): LayerIcon { @@ -211,6 +212,11 @@ export class GeoJsonVectorLayer extends AbstractVectorLayer { await this._syncData(syncContext, this.getSource(), this.getCurrentStyle()); } + _isLoadingBounds() { + const boundsDataRequest = this.getDataRequest(SOURCE_BOUNDS_DATA_REQUEST_ID); + return !!boundsDataRequest && boundsDataRequest.isLoading(); + } + // TLDR: Do not call getSource or getCurrentStyle in syncData flow. Use 'source' and 'style' arguments instead. // // 1) State is contained in the redux store. Layer instance state is readonly. @@ -222,7 +228,7 @@ export class GeoJsonVectorLayer extends AbstractVectorLayer { // Given 2 above, which source/style to use can not be pulled from data request state. // Therefore, source and style are provided as arugments and must be used instead of calling getSource or getCurrentStyle. async _syncData(syncContext: DataRequestContext, source: IVectorSource, style: IVectorStyle) { - if (this.isLoadingBounds()) { + if (this._isLoadingBounds()) { return; } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx index 7eaec94eac0a2..a16093af20426 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx @@ -45,6 +45,7 @@ import { getAggsMeta, getHitsMeta, } from '../../../util/tile_meta_feature_utils'; +import { syncBoundsData } from '../bounds_data'; const MAX_RESULT_WINDOW_DATA_REQUEST_ID = 'maxResultWindow'; @@ -77,7 +78,7 @@ export class MvtVectorLayer extends AbstractVectorLayer { : super.isInitialDataLoadComplete(); } - async getBounds(syncContext: DataRequestContext) { + async getBounds(getDataRequestContext: (layerId: string) => DataRequestContext) { // Add filter to narrow bounds to features with matching join keys let joinKeyFilter; if (this.getSource().isESSource()) { @@ -93,12 +94,18 @@ export class MvtVectorLayer extends AbstractVectorLayer { } } - return super.getBounds({ - ...syncContext, - dataFilters: { - ...syncContext.dataFilters, - joinKeyFilter, + const syncContext = getDataRequestContext(this.getId()); + return syncBoundsData({ + layerId: this.getId(), + syncContext: { + ...syncContext, + dataFilters: { + ...syncContext.dataFilters, + joinKeyFilter, + }, }, + source: this.getSource(), + sourceQuery: this.getQuery(), }); } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.test.tsx index b71fef484de01..d450f92467e46 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.test.tsx @@ -87,7 +87,9 @@ describe('cloneDescriptor', () => { source: new MockSource() as unknown as IVectorSource, customIcons: [], }); - const clonedDescriptor = await layer.cloneDescriptor(); + const clones = await layer.cloneDescriptor(); + expect(clones.length).toBe(1); + const clonedDescriptor = clones[0]; const clonedStyleProps = (clonedDescriptor.style as VectorStyleDescriptor).properties; // Should update style field belonging to join // @ts-expect-error @@ -124,7 +126,9 @@ describe('cloneDescriptor', () => { source: new MockSource() as unknown as IVectorSource, customIcons: [], }); - const clonedDescriptor = await layer.cloneDescriptor(); + const clones = await layer.cloneDescriptor(); + expect(clones.length).toBe(1); + const clonedDescriptor = clones[0]; const clonedStyleProps = (clonedDescriptor.style as VectorStyleDescriptor).properties; // Should update style field belonging to join // @ts-expect-error diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index 35a5caa7ff9b8..27768dc717bd7 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -162,8 +162,13 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { ); } - async cloneDescriptor(): Promise { - const clonedDescriptor = (await super.cloneDescriptor()) as VectorLayerDescriptor; + async cloneDescriptor(): Promise { + const clones = await super.cloneDescriptor(); + if (clones.length === 0) { + return []; + } + + const clonedDescriptor = clones[0] as VectorLayerDescriptor; if (clonedDescriptor.joins) { clonedDescriptor.joins.forEach((joinDescriptor: JoinDescriptor) => { if (joinDescriptor.right && joinDescriptor.right.type === SOURCE_TYPES.TABLE_SOURCE) { @@ -215,7 +220,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { } }); } - return clonedDescriptor; + return [clonedDescriptor]; } getSource(): IVectorSource { @@ -295,10 +300,10 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { return this.getCurrentStyle().renderLegendDetails(); } - async getBounds(syncContext: DataRequestContext) { + async getBounds(getDataRequestContext: (layerId: string) => DataRequestContext) { return syncBoundsData({ layerId: this.getId(), - syncContext, + syncContext: getDataRequestContext(this.getId()), source: this.getSource(), sourceQuery: this.getQuery(), }); diff --git a/x-pack/plugins/maps/public/components/remove_layer_confirm_modal.tsx b/x-pack/plugins/maps/public/components/remove_layer_confirm_modal.tsx new file mode 100644 index 0000000000000..8c35750265cd6 --- /dev/null +++ b/x-pack/plugins/maps/public/components/remove_layer_confirm_modal.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiConfirmModal, EuiText } from '@elastic/eui'; +import { ILayer } from '../classes/layers/layer'; +import { isLayerGroup, LayerGroup } from '../classes/layers/layer_group'; + +export interface Props { + layer: ILayer; + onCancel: () => void; + onConfirm: () => void; +} + +export function RemoveLayerConfirmModal(props: Props) { + function getChildrenCount(layerGroup: LayerGroup) { + let count = 0; + layerGroup.getChildren().forEach((childLayer) => { + count++; + if (isLayerGroup(childLayer)) { + count = count + getChildrenCount(childLayer as LayerGroup); + } + }); + return count; + } + + function renderMultiLayerWarning() { + if (!isLayerGroup(props.layer)) { + return null; + } + + const numChildren = getChildrenCount(props.layer as LayerGroup); + return numChildren > 0 ? ( +

+ {i18n.translate('xpack.maps.deleteLayerConfirmModal.multiLayerWarning', { + defaultMessage: `Removing this layer also removes {numChildren} nested {numChildren, plural, one {layer} other {layers}}.`, + values: { numChildren }, + })} +

+ ) : null; + } + + return ( + + + {renderMultiLayerWarning()} +

+ {i18n.translate('xpack.maps.deleteLayerConfirmModal.unrecoverableWarning', { + defaultMessage: `You can't recover removed layers.`, + })} +

+
+
+ ); +} diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/edit_layer_panel.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/edit_layer_panel.tsx index 906947562f940..8ef8319a82798 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/edit_layer_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/edit_layer_panel.tsx @@ -35,6 +35,7 @@ import { ILayer } from '../../classes/layers/layer'; import { isVectorLayer, IVectorLayer } from '../../classes/layers/vector_layer'; import { ImmutableSourceProperty, OnSourceChangeArgs } from '../../classes/sources/source'; import { IField } from '../../classes/fields/field'; +import { isLayerGroup } from '../../classes/layers/layer_group'; const localStorage = new Storage(window.localStorage); @@ -95,7 +96,7 @@ export class EditLayerPanel extends Component { }; _loadImmutableSourceProperties = async () => { - if (!this.props.selectedLayer) { + if (!this.props.selectedLayer || isLayerGroup(this.props.selectedLayer)) { return; } @@ -160,7 +161,11 @@ export class EditLayerPanel extends Component { } _renderFilterSection() { - if (!this.props.selectedLayer || !this.props.selectedLayer.supportsElasticsearchFilters()) { + if ( + !this.props.selectedLayer || + isLayerGroup(this.props.selectedLayer) || + !this.props.selectedLayer.supportsElasticsearchFilters() + ) { return null; } @@ -197,35 +202,70 @@ export class EditLayerPanel extends Component { ); } - _renderSourceProperties() { - return this.state.immutableSourceProps.map( - ({ label, value, link }: ImmutableSourceProperty) => { - function renderValue() { - if (link) { - return ( - - {value} - - ); - } - return {value}; - } - return ( -

- {label} {renderValue()} -

- ); - } + _renderSourceDetails() { + return !this.props.selectedLayer || isLayerGroup(this.props.selectedLayer) ? null : ( +
+ + + + {this.state.immutableSourceProps.map( + ({ label, value, link }: ImmutableSourceProperty) => { + function renderValue() { + if (link) { + return ( + + {value} + + ); + } + return {value}; + } + return ( +

+ {label} {renderValue()} +

+ ); + } + )} +
+
+
); } - render() { + _renderSourceEditor() { if (!this.props.selectedLayer) { return null; } const descriptor = this.props.selectedLayer.getDescriptor() as VectorLayerDescriptor; const numberOfJoins = descriptor.joins ? descriptor.joins.length : 0; + return isLayerGroup(this.props.selectedLayer) + ? null + : this.props.selectedLayer.renderSourceSettingsEditor({ + currentLayerType: this.props.selectedLayer.getType(), + numberOfJoins, + onChange: this._onSourceChange, + onStyleDescriptorChange: this.props.updateStyleDescriptor, + style: this.props.selectedLayer.getStyleForEditing(), + }); + } + + _renderStyleEditor() { + return !this.props.selectedLayer || isLayerGroup(this.props.selectedLayer) ? null : ( + + ); + } + + render() { + if (!this.props.selectedLayer) { + return null; + } return ( { -
- - - - {this._renderSourceProperties()} - - -
+ {this._renderSourceDetails()}
@@ -273,19 +301,13 @@ export class EditLayerPanel extends Component { supportsFitToBounds={this.state.supportsFitToBounds} /> - {this.props.selectedLayer.renderSourceSettingsEditor({ - currentLayerType: this.props.selectedLayer.getType(), - numberOfJoins, - onChange: this._onSourceChange, - onStyleDescriptorChange: this.props.updateStyleDescriptor, - style: this.props.selectedLayer.getStyleForEditing(), - })} + {this._renderSourceEditor()} {this._renderFilterSection()} {this._renderJoinSection()} - + {this._renderStyleEditor()}
diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/flyout_footer/flyout_footer.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/flyout_footer/flyout_footer.tsx index b9761f5d48430..614fbfcebe4e1 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/flyout_footer/flyout_footer.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/flyout_footer/flyout_footer.tsx @@ -5,69 +5,102 @@ * 2.0. */ -import React from 'react'; +import React, { Component } from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { ILayer } from '../../../classes/layers/layer'; +import { RemoveLayerConfirmModal } from '../../../components/remove_layer_confirm_modal'; export interface Props { + selectedLayer?: ILayer; cancelLayerPanel: () => void; saveLayerEdits: () => void; removeLayer: () => void; hasStateChanged: boolean; } -export const FlyoutFooter = ({ - cancelLayerPanel, - saveLayerEdits, - removeLayer, - hasStateChanged, -}: Props) => { - const removeBtn = ( - - - - - - ); +interface State { + showRemoveModal: boolean; +} + +export class FlyoutFooter extends Component { + state: State = { + showRemoveModal: false, + }; + + _showRemoveModal = () => { + this.setState({ showRemoveModal: true }); + }; - const cancelButtonLabel = hasStateChanged ? ( - - ) : ( - - ); + render() { + const cancelButtonLabel = this.props.hasStateChanged ? ( + + ) : ( + + ); - return ( - - - - {cancelButtonLabel} - - - - - - {removeBtn} - - - - - - - ); -}; + const removeModal = + this.props.selectedLayer && this.state.showRemoveModal ? ( + { + this.setState({ showRemoveModal: false }); + }} + onConfirm={() => { + this.setState({ showRemoveModal: false }); + this.props.removeLayer(); + }} + /> + ) : null; + + return ( + <> + {removeModal} + + + + {cancelButtonLabel} + + + + + + + + + + + + + + + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/flyout_footer/index.ts b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/flyout_footer/index.ts index 8546b8088d40a..093f0524b271b 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/flyout_footer/index.ts +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/flyout_footer/index.ts @@ -11,7 +11,7 @@ import { connect } from 'react-redux'; import { FlyoutFooter } from './flyout_footer'; import { FLYOUT_STATE } from '../../../reducers/ui'; -import { hasDirtyState } from '../../../selectors/map_selectors'; +import { getSelectedLayer, hasDirtyState } from '../../../selectors/map_selectors'; import { setSelectedLayer, removeSelectedLayer, @@ -23,6 +23,7 @@ import { MapStoreState } from '../../../reducers/store'; function mapStateToProps(state: MapStoreState) { return { hasStateChanged: hasDirtyState(state), + selectedLayer: getSelectedLayer(state), }; } diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx index 794064e09d3c6..6d63fc08ef85e 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/layer_settings/layer_settings.tsx @@ -26,6 +26,7 @@ import { AlphaSlider } from '../../../components/alpha_slider'; import { ILayer } from '../../../classes/layers/layer'; import { isVectorLayer, IVectorLayer } from '../../../classes/layers/vector_layer'; import { AttributionFormRow } from './attribution_form_row'; +import { isLayerGroup } from '../../../classes/layers/layer_group'; export interface Props { layer: ILayer; @@ -87,7 +88,7 @@ export function LayerSettings(props: Props) { }; const renderIncludeInFitToBounds = () => { - if (!props.supportsFitToBounds) { + if (!props.supportsFitToBounds || isLayerGroup(props.layer)) { return null; } return ( @@ -113,7 +114,7 @@ export function LayerSettings(props: Props) { }; const renderZoomSliders = () => { - return ( + return isLayerGroup(props.layer) ? null : ( {renderLabel()} {renderZoomSliders()} - + {isLayerGroup(props.layer) ? null : ( + + )} {renderShowLabelsOnTop()} {renderShowLocaleSelector()} - + {isLayerGroup(props.layer) ? null : ( + + )} {renderIncludeInFitToBounds()} {renderDisableTooltips()} diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/_index.scss b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/_index.scss index 9a3e3a45d6c4e..9ca24d055432b 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/_index.scss @@ -1,2 +1,3 @@ @import 'layer_control'; +@import 'layer_toc/layer_toc'; @import 'layer_toc/toc_entry/toc_entry'; diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/__snapshots__/layer_toc.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/__snapshots__/layer_toc.test.tsx.snap index fbd83ed145a08..7b0741e4bc74a 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/__snapshots__/layer_toc.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/__snapshots__/layer_toc.test.tsx.snap @@ -6,9 +6,12 @@ exports[`LayerTOC is rendered 1`] = ` > @@ -22,19 +25,23 @@ exports[`LayerTOC props isReadOnly 1`] = ` data-test-subj="mapLayerTOC" > ) { return { - updateLayerOrder: (newOrder: number[]) => dispatch(updateLayerOrder(newOrder)), + createLayerGroup: (draggedLayerId: string, combineWithLayerId: string) => + dispatch(createLayerGroup(draggedLayerId, combineWithLayerId)), + moveLayerToBottom: (moveLayerId: string) => dispatch(moveLayerToBottom(moveLayerId)), + moveLayerToLeftOfTarget: (moveLayerId: string, targetLayerId: string) => + dispatch(moveLayerToLeftOfTarget(moveLayerId, targetLayerId)), + setLayerParent: (layerId: string, parent: string | undefined) => + dispatch(setLayerParent(layerId, parent)), }; } diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.test.tsx index 359794f1468f6..b7ee829b67368 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.test.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.test.tsx @@ -22,6 +22,9 @@ const mockLayers = [ getId: () => { return '1'; }, + getParent: () => { + return undefined; + }, supportsFitToBounds: () => { return true; }, @@ -30,6 +33,9 @@ const mockLayers = [ getId: () => { return '2'; }, + getParent: () => { + return undefined; + }, supportsFitToBounds: () => { return false; }, @@ -39,7 +45,11 @@ const mockLayers = [ const defaultProps = { layerList: mockLayers, isReadOnly: false, - updateLayerOrder: () => {}, + openTOCDetails: [], + moveLayerToBottom: () => {}, + moveLayerToLeftOfTarget: () => {}, + setLayerParent: () => {}, + createLayerGroup: () => {}, }; describe('LayerTOC', () => { diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.tsx index 1800f2dc33618..f152d1686b3bd 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/layer_toc.tsx @@ -9,15 +9,38 @@ import _ from 'lodash'; import React, { Component } from 'react'; import { DropResult, EuiDragDropContext, EuiDroppable, EuiDraggable } from '@elastic/eui'; import { TOCEntry } from './toc_entry'; +import { isLayerGroup } from '../../../../classes/layers/layer_group'; import { ILayer } from '../../../../classes/layers/layer'; export interface Props { isReadOnly: boolean; layerList: ILayer[]; - updateLayerOrder: (newOrder: number[]) => void; + openTOCDetails: string[]; + createLayerGroup: (draggedLayerId: string, combineWithLayerId: string) => void; + setLayerParent: (layerId: string, parent: string | undefined) => void; + moveLayerToBottom: (moveLayerId: string) => void; + moveLayerToLeftOfTarget: (moveLayerId: string, targetLayerId: string) => void; } +interface State { + combineLayer: ILayer | null; + isOwnAncestor: boolean; + newRightSiblingLayer: ILayer | null; + sourceLayer: ILayer | null; +} + +const CLEAR_DND_STATE = { + combineLayer: null, + isOwnAncestor: false, + newRightSiblingLayer: null, + sourceLayer: null, +}; + export class LayerTOC extends Component { + state: State = { + ...CLEAR_DND_STATE, + }; + componentWillUnmount() { this._updateDebounced.cancel(); } @@ -29,60 +52,201 @@ export class LayerTOC extends Component { _updateDebounced = _.debounce(this.forceUpdate, 100); - _onDragEnd = ({ source, destination }: DropResult) => { - // Dragging item out of EuiDroppable results in destination of null - if (!destination) { + _reverseIndex(index: number) { + return this.props.layerList.length - index - 1; + } + + _getForebearers(layer: ILayer): string[] { + const parentId = layer.getParent(); + if (!parentId) { + return []; + } + + const parentLayer = this.props.layerList.find((findLayer) => { + return findLayer.getId() === parentId; + }); + if (!parentLayer) { + return []; + } + + return [...this._getForebearers(parentLayer), parentId]; + } + + _onDragStart = ({ source }: DropResult) => { + const sourceIndex = this._reverseIndex(source.index); + const sourceLayer = this.props.layerList[sourceIndex]; + this.setState({ ...CLEAR_DND_STATE, sourceLayer }); + }; + + _onDragUpdate = ({ combine, destination, source }: DropResult) => { + const sourceIndex = this._reverseIndex(source.index); + const sourceLayer = this.props.layerList[sourceIndex]; + + if (combine) { + const combineIndex = this.props.layerList.findIndex((findLayer) => { + return findLayer.getId() === combine.draggableId; + }); + const combineLayer = combineIndex !== -1 ? this.props.layerList[combineIndex] : null; + + const newRightSiblingIndex = combineIndex - 1; + const newRightSiblingLayer = + newRightSiblingIndex < 0 ? null : this.props.layerList[newRightSiblingIndex]; + + const forebearers = combineLayer ? this._getForebearers(combineLayer) : []; + + this.setState({ + combineLayer, + newRightSiblingLayer, + sourceLayer, + isOwnAncestor: forebearers.includes(sourceLayer.getId()), + }); + return; + } + + if (!destination || source.index === destination.index) { + this.setState({ ...CLEAR_DND_STATE }); return; } - // Layer list is displayed in reverse order so index needs to reversed to get back to original reference. - const reverseIndex = (index: number) => { - return this.props.layerList.length - index - 1; - }; + const destinationIndex = this._reverseIndex(destination.index); + const newRightSiblingIndex = + sourceIndex > destinationIndex + ? // When layer is moved to the right, new right sibling is layer to the right of destination + destinationIndex - 1 + : // When layer is moved to the left, new right sibling is the destination + destinationIndex; + const newRightSiblingLayer = + newRightSiblingIndex < 0 ? null : this.props.layerList[newRightSiblingIndex]; + + const forebearers = newRightSiblingLayer ? this._getForebearers(newRightSiblingLayer) : []; - const prevIndex = reverseIndex(source.index); - const newIndex = reverseIndex(destination.index); - const newOrder = []; - for (let i = 0; i < this.props.layerList.length; i++) { - newOrder.push(i); + this.setState({ + combineLayer: null, + newRightSiblingLayer, + sourceLayer, + isOwnAncestor: forebearers.includes(sourceLayer.getId()), + }); + }; + + _onDragEnd = () => { + const { combineLayer, isOwnAncestor, sourceLayer, newRightSiblingLayer } = this.state; + this.setState({ ...CLEAR_DND_STATE }); + + if (isOwnAncestor || !sourceLayer) { + return; + } + + if (combineLayer) { + // add source to layer group when combine is layer group + if (isLayerGroup(combineLayer) && newRightSiblingLayer) { + this.props.setLayerParent(sourceLayer.getId(), combineLayer.getId()); + this.props.moveLayerToLeftOfTarget(sourceLayer.getId(), newRightSiblingLayer.getId()); + return; + } + + // creage layer group that contains source and combine + this.props.createLayerGroup(sourceLayer.getId(), combineLayer.getId()); + return; } - newOrder.splice(prevIndex, 1); - newOrder.splice(newIndex, 0, prevIndex); - this.props.updateLayerOrder(newOrder); + + if (newRightSiblingLayer) { + this.props.setLayerParent(sourceLayer.getId(), newRightSiblingLayer.getParent()); + this.props.moveLayerToLeftOfTarget(sourceLayer.getId(), newRightSiblingLayer.getId()); + return; + } + + this.props.moveLayerToBottom(sourceLayer.getId()); }; + _getDepth(layer: ILayer, depth: number): { depth: number; showInTOC: boolean } { + if (layer.getParent() === undefined) { + return { depth, showInTOC: true }; + } + + const parent = this.props.layerList.find((nextLayer) => { + return layer.getParent() === nextLayer.getId(); + }); + if (!parent) { + return { depth, showInTOC: false }; + } + + return this.props.openTOCDetails.includes(parent.getId()) + ? this._getDepth(parent, depth + 1) + : { depth, showInTOC: false }; + } + + _getDroppableClass() { + if (!this.state.sourceLayer) { + // nothing is dragged + return ''; + } + + if (this.state.isOwnAncestor) { + return 'mapLayerToc-droppable-dropNotAllowed'; + } + + if (this.state.combineLayer) { + return 'mapLayerToc-droppable-isCombining'; + } + + return 'mapLayerToc-droppable-isDragging'; + } + _renderLayers() { - // Reverse layer list so first layer drawn on map is at the bottom and - // last layer drawn on map is at the top. - const reverseLayerList = [...this.props.layerList].reverse(); + const tocEntryList = this.props.layerList + .map((layer, index) => { + return { + ...this._getDepth(layer, 0), + draggableIndex: this._reverseIndex(index), + layer, + }; + }) + .filter(({ showInTOC }) => { + return showInTOC; + }) + // Reverse layer list so first layer drawn on map is at the bottom and + // last layer drawn on map is at the top. + .reverse(); if (this.props.isReadOnly) { - return reverseLayerList.map((layer) => { - return ; + return tocEntryList.map(({ depth, layer }) => { + return ; }); } return ( - - - {(droppableProvided, snapshot) => { - const tocEntries = reverseLayerList.map((layer, idx: number) => ( + + + {(droppableProvided, droppableSnapshot) => { + const tocEntries = tocEntryList.map(({ draggableIndex, depth, layer }) => ( - {(provided, state) => ( - - )} + {(draggableProvided, draggableSnapshot) => { + return ( + + ); + }} )); return
{tocEntries}
; diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/__snapshots__/toc_entry.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/__snapshots__/toc_entry.test.tsx.snap index cec85cb0e1cd6..0973bd4f24459 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/__snapshots__/toc_entry.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/__snapshots__/toc_entry.test.tsx.snap @@ -5,6 +5,94 @@ exports[`TOCEntry is rendered 1`] = ` className="mapTocEntry" data-layerid="1" id="1" + style={Object {}} +> +
+ +
+ + + + +
+
+ + + + +`; + +exports[`TOCEntry props Should indent child layer 1`] = ` +
{}, @@ -93,6 +94,17 @@ describe('TOCEntry', () => { expect(component).toMatchSnapshot(); }); + test('Should indent child layer', async () => { + const component = shallow(); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + test('should display layer details when isLegendDetailsOpen is true', async () => { const component = shallow(); diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry.tsx index 65431432d8c6d..72eb38f07257e 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry.tsx @@ -44,6 +44,7 @@ export interface ReduxDispatchProps { } export interface OwnProps { + depth: number; layer: ILayer; dragHandleProps?: DraggableProvidedDragHandleProps; isDragging?: boolean; @@ -226,7 +227,7 @@ export class TOCEntry extends Component { } _renderDetailsToggle() { - if (!this.state.hasLegendDetails) { + if (this.props.isDragging || !this.state.hasLegendDetails) { return null; } @@ -319,8 +320,12 @@ export class TOCEntry extends Component { 'mapTocEntry-isInEditingMode': this.props.isFeatureEditorOpenForLayer, }); + const depthStyle = + this.props.depth > 0 ? { paddingLeft: `${8 + this.props.depth * 24}px` } : {}; + return (
+ + } + className="mapLayTocActions" + closePopover={[Function]} + display="inline-block" + hasArrow={true} + id="testLayer" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerSettingsButton", + "disabled": false, + "icon": , + "name": "Edit layer settings", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "cloneLayerButton", + "icon": , + "name": "Clone layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "removeLayerButton", + "icon": , + "name": "Remove layer", + "onClick": [Function], + "toolTipContent": null, + }, + ], + "title": "Layer actions", + }, + ] } - onClick={[Function]} + size="m" /> - } - className="mapLayTocActions" - closePopover={[Function]} - display="inline-block" - hasArrow={true} - id="testLayer" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" -> - , - "name": "Fit to data", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "layerVisibilityToggleButton", - "icon": , - "name": "Hide layer", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "layerSettingsButton", - "disabled": false, - "icon": , - "name": "Edit layer settings", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "cloneLayerButton", - "icon": , - "name": "Clone layer", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "removeLayerButton", - "icon": , - "name": "Remove layer", - "onClick": [Function], - "toolTipContent": null, - }, - ], - "title": "Layer actions", - }, - ] - } - size="m" - /> - + + `; exports[`TOCEntryActionsPopover should disable Edit features when edit mode active for layer 1`] = ` - + + } + className="mapLayTocActions" + closePopover={[Function]} + display="inline-block" + hasArrow={true} + id="testLayer" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerSettingsButton", + "disabled": false, + "icon": , + "name": "Edit layer settings", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "cloneLayerButton", + "icon": , + "name": "Clone layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "removeLayerButton", + "icon": , + "name": "Remove layer", + "onClick": [Function], + "toolTipContent": null, + }, + ], + "title": "Layer actions", + }, + ] } - onClick={[Function]} + size="m" /> - } - className="mapLayTocActions" - closePopover={[Function]} - display="inline-block" - hasArrow={true} - id="testLayer" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" -> - , - "name": "Fit to data", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "layerVisibilityToggleButton", - "icon": , - "name": "Hide layer", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "layerSettingsButton", - "disabled": false, - "icon": , - "name": "Edit layer settings", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "cloneLayerButton", - "icon": , - "name": "Clone layer", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "removeLayerButton", - "icon": , - "name": "Remove layer", - "onClick": [Function], - "toolTipContent": null, - }, - ], - "title": "Layer actions", - }, - ] - } - size="m" - /> - + + `; exports[`TOCEntryActionsPopover should disable fit to data when supportsFitToBounds is false 1`] = ` - + + } + className="mapLayTocActions" + closePopover={[Function]} + display="inline-block" + hasArrow={true} + id="testLayer" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": "Layer does not support fit to data", + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerSettingsButton", + "disabled": false, + "icon": , + "name": "Edit layer settings", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "cloneLayerButton", + "icon": , + "name": "Clone layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "removeLayerButton", + "icon": , + "name": "Remove layer", + "onClick": [Function], + "toolTipContent": null, + }, + ], + "title": "Layer actions", + }, + ] } - onClick={[Function]} + size="m" /> - } - className="mapLayTocActions" - closePopover={[Function]} - display="inline-block" - hasArrow={true} - id="testLayer" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" -> - , - "name": "Fit to data", - "onClick": [Function], - "toolTipContent": "Layer does not support fit to data", - }, - Object { - "data-test-subj": "layerVisibilityToggleButton", - "icon": , - "name": "Hide layer", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "layerSettingsButton", - "disabled": false, - "icon": , - "name": "Edit layer settings", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "cloneLayerButton", - "icon": , - "name": "Clone layer", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "removeLayerButton", - "icon": , - "name": "Remove layer", - "onClick": [Function], - "toolTipContent": null, - }, - ], - "title": "Layer actions", - }, - ] - } - size="m" - /> - + + `; exports[`TOCEntryActionsPopover should have "show layer" action when layer is not visible 1`] = ` - + + } + className="mapLayTocActions" + closePopover={[Function]} + display="inline-block" + hasArrow={true} + id="testLayer" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Show layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerSettingsButton", + "disabled": false, + "icon": , + "name": "Edit layer settings", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "cloneLayerButton", + "icon": , + "name": "Clone layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "removeLayerButton", + "icon": , + "name": "Remove layer", + "onClick": [Function], + "toolTipContent": null, + }, + ], + "title": "Layer actions", + }, + ] } - onClick={[Function]} + size="m" /> - } - className="mapLayTocActions" - closePopover={[Function]} - display="inline-block" - hasArrow={true} - id="testLayer" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" -> - , - "name": "Fit to data", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "layerVisibilityToggleButton", - "icon": , - "name": "Show layer", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "layerSettingsButton", - "disabled": false, - "icon": , - "name": "Edit layer settings", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "cloneLayerButton", - "icon": , - "name": "Clone layer", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "removeLayerButton", - "icon": , - "name": "Remove layer", - "onClick": [Function], - "toolTipContent": null, - }, - ], - "title": "Layer actions", - }, - ] - } - size="m" - /> - + + `; exports[`TOCEntryActionsPopover should not show edit actions in read only mode 1`] = ` - + + } + className="mapLayTocActions" + closePopover={[Function]} + display="inline-block" + hasArrow={true} + id="testLayer" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + "toolTipContent": null, + }, + ], + "title": "Layer actions", + }, + ] } - onClick={[Function]} + size="m" /> - } - className="mapLayTocActions" - closePopover={[Function]} - display="inline-block" - hasArrow={true} - id="testLayer" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" -> - , - "name": "Fit to data", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "layerVisibilityToggleButton", - "icon": , - "name": "Hide layer", - "onClick": [Function], - "toolTipContent": null, - }, - ], - "title": "Layer actions", - }, - ] - } - size="m" - /> - + + `; exports[`TOCEntryActionsPopover should show "show this layer only" action when there are more then 2 layers 1`] = ` - + + } + className="mapLayTocActions" + closePopover={[Function]} + display="inline-block" + hasArrow={true} + id="testLayer" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "showThisLayerOnlyButton", + "icon": , + "name": "Show this layer only", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerSettingsButton", + "disabled": false, + "icon": , + "name": "Edit layer settings", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "cloneLayerButton", + "icon": , + "name": "Clone layer", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "removeLayerButton", + "icon": , + "name": "Remove layer", + "onClick": [Function], + "toolTipContent": null, + }, + ], + "title": "Layer actions", + }, + ] } - onClick={[Function]} + size="m" /> - } - className="mapLayTocActions" - closePopover={[Function]} - display="inline-block" - hasArrow={true} - id="testLayer" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" -> - , - "name": "Fit to data", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "layerVisibilityToggleButton", - "icon": , - "name": "Hide layer", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "showThisLayerOnlyButton", - "icon": , - "name": "Show this layer only", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "layerSettingsButton", - "disabled": false, - "icon": , - "name": "Edit layer settings", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "cloneLayerButton", - "icon": , - "name": "Clone layer", - "onClick": [Function], - "toolTipContent": null, - }, - Object { - "data-test-subj": "removeLayerButton", - "icon": , - "name": "Remove layer", - "onClick": [Function], - "toolTipContent": null, - }, - ], - "title": "Layer actions", - }, - ] - } - size="m" - /> - + + `; diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx index 5e33931a8943e..a67c12d2928a4 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx @@ -20,6 +20,7 @@ import { import { ESSearchSource } from '../../../../../../classes/sources/es_search_source'; import { isVectorLayer, IVectorLayer } from '../../../../../../classes/layers/vector_layer'; import { SCALING_TYPES, VECTOR_SHAPE_TYPE } from '../../../../../../../common/constants'; +import { RemoveLayerConfirmModal } from '../../../../../../components/remove_layer_confirm_modal'; export interface Props { cloneLayer: (layerId: string) => void; @@ -41,6 +42,7 @@ export interface Props { interface State { isPopoverOpen: boolean; + showRemoveModal: boolean; supportsFeatureEditing: boolean; isFeatureEditingEnabled: boolean; } @@ -48,6 +50,7 @@ interface State { export class TOCEntryActionsPopover extends Component { state: State = { isPopoverOpen: false, + showRemoveModal: false, supportsFeatureEditing: false, isFeatureEditingEnabled: false, }; @@ -119,10 +122,6 @@ export class TOCEntryActionsPopover extends Component { this.props.fitToBounds(this.props.layer.getId()); } - _removeLayer() { - this.props.removeLayer(this.props.layer.getId()); - } - _toggleVisible() { this.props.toggleVisible(this.props.layer.getId()); } @@ -230,8 +229,7 @@ export class TOCEntryActionsPopover extends Component { toolTipContent: null, 'data-test-subj': 'removeLayerButton', onClick: () => { - this._closePopover(); - this._removeLayer(); + this.setState({ showRemoveModal: true }); }, }); } @@ -246,30 +244,46 @@ export class TOCEntryActionsPopover extends Component { } render() { + const removeModal = this.state.showRemoveModal ? ( + { + this.setState({ showRemoveModal: false }); + }} + onConfirm={() => { + this.setState({ showRemoveModal: false }); + this._closePopover(); + this.props.removeLayer(this.props.layer.getId()); + }} + /> + ) : null; return ( - + {removeModal} + + } + isOpen={this.state.isPopoverOpen} + closePopover={this._closePopover} + panelPaddingSize="none" + anchorPosition="leftUp" + anchorClassName="mapLayTocActions__popoverAnchor" + > + - } - isOpen={this.state.isPopoverOpen} - closePopover={this._closePopover} - panelPaddingSize="none" - anchorPosition="leftUp" - anchorClassName="mapLayTocActions__popoverAnchor" - > - - + + ); } } diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx index c55821c522d14..7e35447d45e41 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_button/toc_entry_button.tsx @@ -11,6 +11,7 @@ import { EuiButtonEmpty, EuiIcon, EuiToolTip, EuiLoadingSpinner } from '@elastic import { i18n } from '@kbn/i18n'; import { ILayer } from '../../../../../../classes/layers/layer'; import { IVectorSource } from '../../../../../../classes/sources/vector_source'; +import { isLayerGroup } from '../../../../../../classes/layers/layer_group'; interface Footnote { icon: ReactNode; @@ -69,72 +70,88 @@ export class TOCEntryButton extends Component { } getIconAndTooltipContent(): IconAndTooltipContent { - let icon; - let tooltipContent = null; - const footnotes = []; if (this.props.layer.hasErrors()) { - icon = ( - - ); - tooltipContent = this.props.layer.getErrors(); - } else if (!this.props.layer.isVisible()) { - icon = ; - tooltipContent = i18n.translate('xpack.maps.layer.layerHiddenTooltip', { - defaultMessage: `Layer is hidden.`, - }); - } else if (this.props.layer.isLayerLoading()) { - icon = ; - } else if (!this.props.layer.showAtZoomLevel(this.props.zoom)) { + return { + icon: ( + + ), + tooltipContent: this.props.layer.getErrors(), + footnotes: [], + }; + } + + if (!this.props.layer.isVisible()) { + return { + icon: , + tooltipContent: i18n.translate('xpack.maps.layer.layerHiddenTooltip', { + defaultMessage: `Layer is hidden.`, + }), + footnotes: [], + }; + } + + if (this.props.layer.isLayerLoading()) { + return { + icon: , + tooltipContent: '', + footnotes: [], + }; + } + + if (!this.props.layer.showAtZoomLevel(this.props.zoom)) { const minZoom = this.props.layer.getMinZoom(); const maxZoom = this.props.layer.getMaxZoom(); - icon = ; - tooltipContent = i18n.translate('xpack.maps.layer.zoomFeedbackTooltip', { - defaultMessage: `Layer is visible between zoom levels {minZoom} and {maxZoom}.`, - values: { minZoom, maxZoom }, + return { + icon: , + tooltipContent: i18n.translate('xpack.maps.layer.zoomFeedbackTooltip', { + defaultMessage: `Layer is visible between zoom levels {minZoom} and {maxZoom}.`, + values: { minZoom, maxZoom }, + }), + footnotes: [], + }; + } + + const { icon, tooltipContent } = this.props.layer.getLayerIcon(true); + + if (isLayerGroup(this.props.layer)) { + return { icon, tooltipContent, footnotes: [] }; + } + + const footnotes = []; + if (this.props.isUsingSearch && this.props.layer.getQueryableIndexPatternIds().length) { + footnotes.push({ + icon: , + message: i18n.translate('xpack.maps.layer.isUsingSearchMsg', { + defaultMessage: 'Results narrowed by global search', + }), + }); + } + if (this.state.isFilteredByGlobalTime) { + footnotes.push({ + icon: , + message: i18n.translate('xpack.maps.layer.isUsingTimeFilter', { + defaultMessage: 'Results narrowed by global time', + }), + }); + } + const source = this.props.layer.getSource(); + if ( + typeof source.isFilterByMapBounds === 'function' && + (source as IVectorSource).isFilterByMapBounds() + ) { + footnotes.push({ + icon: , + message: i18n.translate('xpack.maps.layer.isUsingBoundsFilter', { + defaultMessage: 'Results narrowed by visible map area', + }), }); - } else { - const { icon: layerIcon, tooltipContent: layerTooltipContent } = - this.props.layer.getLayerIcon(true); - icon = layerIcon; - if (layerTooltipContent) { - tooltipContent = layerTooltipContent; - } - - if (this.props.isUsingSearch && this.props.layer.getQueryableIndexPatternIds().length) { - footnotes.push({ - icon: , - message: i18n.translate('xpack.maps.layer.isUsingSearchMsg', { - defaultMessage: 'Results narrowed by global search', - }), - }); - } - if (this.state.isFilteredByGlobalTime) { - footnotes.push({ - icon: , - message: i18n.translate('xpack.maps.layer.isUsingTimeFilter', { - defaultMessage: 'Results narrowed by global time', - }), - }); - } - const source = this.props.layer.getSource(); - if ( - typeof source.isFilterByMapBounds === 'function' && - (source as IVectorSource).isFilterByMapBounds() - ) { - footnotes.push({ - icon: , - message: i18n.translate('xpack.maps.layer.isUsingBoundsFilter', { - defaultMessage: 'Results narrowed by visible map area', - }), - }); - } } return { diff --git a/x-pack/plugins/maps/public/reducers/map/layer_utils.ts b/x-pack/plugins/maps/public/reducers/map/layer_utils.ts index 206cc4a740192..bfe7b39fe2868 100644 --- a/x-pack/plugins/maps/public/reducers/map/layer_utils.ts +++ b/x-pack/plugins/maps/public/reducers/map/layer_utils.ts @@ -62,12 +62,7 @@ export function updateLayerInList( const updatedLayer = { ...layerList[layerIdx], - // Update layer w/ new value. If no value provided, toggle boolean value - // allow empty strings, 0-value - [attribute]: - newValue || newValue === '' || newValue === 0 - ? newValue - : !(layerList[layerIdx][attribute] as boolean), + [attribute]: newValue, }; const updatedList = [ ...layerList.slice(0, layerIdx), diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index 6ee55bd72e49d..1d46ef3015046 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -20,6 +20,7 @@ import { GeoJsonVectorLayer, } from '../classes/layers/vector_layer'; import { VectorStyle } from '../classes/styles/vector/vector_style'; +import { isLayerGroup, LayerGroup } from '../classes/layers/layer_group'; import { HeatmapLayer } from '../classes/layers/heatmap_layer'; import { getTimeFilter } from '../kibana_services'; import { getChartsPaletteServiceGetColor } from '../reducers/non_serializable_instances'; @@ -47,6 +48,7 @@ import { Goto, HeatmapLayerDescriptor, LayerDescriptor, + LayerGroupDescriptor, MapCenter, MapExtent, MapSettings, @@ -74,8 +76,11 @@ export function createLayerInstance( customIcons: CustomIcon[], chartsPaletteServiceGetColor?: (value: string) => string | null ): ILayer { - const source: ISource = createSourceInstance(layerDescriptor.sourceDescriptor); + if (layerDescriptor.type === LAYER_TYPE.LAYER_GROUP) { + return new LayerGroup({ layerDescriptor: layerDescriptor as LayerGroupDescriptor }); + } + const source: ISource = createSourceInstance(layerDescriptor.sourceDescriptor); switch (layerDescriptor.type) { case LAYER_TYPE.RASTER_TILE: return new RasterTileLayer({ layerDescriptor, source: source as IRasterSource }); @@ -324,9 +329,32 @@ export const getLayerList = createSelector( getChartsPaletteServiceGetColor, getCustomIcons, (layerDescriptorList, chartsPaletteServiceGetColor, customIcons) => { - return layerDescriptorList.map((layerDescriptor) => + const layers = layerDescriptorList.map((layerDescriptor) => createLayerInstance(layerDescriptor, customIcons, chartsPaletteServiceGetColor) ); + + const childrenMap = new Map(); + layers.forEach((layer) => { + const parent = layer.getParent(); + if (!parent) { + return; + } + + const children = childrenMap.has(parent) ? childrenMap.get(parent)! : []; + childrenMap.set(parent, [...children, layer]); + }); + + childrenMap.forEach((children, parent) => { + const parentLayer = layers.find((layer) => { + return layer.getId() === parent; + }); + if (!parentLayer || !isLayerGroup(parentLayer)) { + return; + } + (parentLayer as LayerGroup).setChildren(children); + }); + + return layers; } ); diff --git a/x-pack/test/functional/page_objects/gis_page.ts b/x-pack/test/functional/page_objects/gis_page.ts index be0d8c9aaf07f..3ad5b00279926 100644 --- a/x-pack/test/functional/page_objects/gis_page.ts +++ b/x-pack/test/functional/page_objects/gis_page.ts @@ -556,6 +556,7 @@ export class GisPageObject extends FtrService { this.log.debug(`Remove layer ${layerName}`); await this.openLayerPanel(layerName); await this.testSubjects.click(`mapRemoveLayerButton`); + await this.common.clickConfirmOnModal(); await this.waitForLayerDeleted(layerName); }