[Maps] layer groups (#142528)

* [Maps] layer groups

* createLayerGroup

* create layer group

* setChildren

* display layer group legend

* display nested layers in TOC

* setLayerVisibility

* set parent on layer re-order

* LayerGroup.getBounds

* clean-up LayerGroup

* edit layer panel

* LayerGroup.cloneDescriptor

* clean up

* remove layer

* fix reorder bug

* move children on layer move

* fix re-order bug when dragging layer group with collapsed details

* add check for dragging to same location

* add logic to prevent dragging layer group into its own family tree

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* add layer to layer group combine action with layer group

* clean up

* fix bug where unable to move layer to bottom

* mouse cursor styles

* clean up combine styling

* fix jest tests

* update toc_entry_actions_popover snapshots

* click confirm model on removeLayer in functional tests

* update cloneLayer to move clones beneath parent

* LayerGroup.getErrors

* Update x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts

Co-authored-by: Nick Peihl <nickpeihl@gmail.com>

* fix show this layer only action when layer is nested

* recursive count children for remove layer warning

* Update x-pack/plugins/maps/public/components/remove_layer_confirm_modal.tsx

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* resolve error with show this layer only on layer group

* update remove statement to support plural

* perserve layer order when cloning layer group

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Nick Peihl <nickpeihl@gmail.com>
Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2022-10-13 10:23:43 -06:00 committed by GitHub
parent 97eb9b6163
commit e45170e50a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 2066 additions and 977 deletions

View file

@ -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 {

View file

@ -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;
};

View file

@ -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 extent = await getLayersExtent(rootLayers, (boundsLayerId) =>
getDataRequestContext(dispatch, getState, boundsLayerId, false, false)
);
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]);
}
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)));
};
}

View file

@ -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<MapExtent | null> {
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;
}

View file

@ -24,3 +24,4 @@ export {
openOnHoverTooltip,
updateOpenTooltips,
} from './tooltip_actions';
export { getLayersExtent } from './get_layers_extent';

View file

@ -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<MapStoreState, void, AnyAction>,
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<MapStoreState, void, AnyAction>,
@ -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<MapStoreState, void, AnyAction>,
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<MapStoreState, void, AnyAction>,
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<MapStoreState, void, AnyAction>,
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()));
});
}
};
}

View file

@ -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(),
});

View file

@ -18,7 +18,7 @@ class MockSource {
this._fitToBounds = fitToBounds;
}
cloneDescriptor() {
return {};
return [{}];
}
async supportsFitToBounds() {

View file

@ -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<MapExtent | null>;
getBounds(
getDataRequestContext: (layerId: string) => DataRequestContext
): Promise<MapExtent | null>;
getDataRequest(id: string): DataRequest | undefined;
getDisplayName(source?: ISource): Promise<string>;
getId(): string;
@ -68,7 +69,6 @@ export interface ILayer {
getImmutableSourceProperties(): Promise<ImmutableSourceProperty[]>;
renderSourceSettingsEditor(sourceEditorArgs: SourceEditorArgs): ReactElement<any> | null;
isLayerLoading(): boolean;
isLoadingBounds(): boolean;
isFilteredByGlobalTime(): Promise<boolean>;
hasErrors(): boolean;
getErrors(): string;
@ -92,7 +92,7 @@ export interface ILayer {
getQueryableIndexPatternIds(): string[];
getType(): LAYER_TYPE;
isVisible(): boolean;
cloneDescriptor(): Promise<LayerDescriptor>;
cloneDescriptor(): Promise<LayerDescriptor[]>;
renderStyleEditor(
onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void,
onCustomIconsChange: (customIcons: CustomIcon[]) => void
@ -117,6 +117,7 @@ export interface ILayer {
getGeoFieldNames(): string[];
getStyleMetaDescriptorFromLocalFeatures(): Promise<StyleMetaDescriptor | null>;
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<LayerDescriptor> {
async cloneDescriptor(): Promise<LayerDescriptor[]> {
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<MapExtent | null> {
async getBounds(
getDataRequestContext: (layerId: string) => DataRequestContext
): Promise<MapExtent | null> {
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 || [];
}

View file

@ -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';

View file

@ -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<LayerDescriptor>): 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<boolean>)();
});
return ((await Promise.all(promises)) as boolean[]).some((result) => {
return result;
});
}
getDescriptor(): LayerGroupDescriptor {
return this._descriptor;
}
async cloneDescriptor(): Promise<LayerDescriptor[]> {
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<boolean> {
return this._asyncSomeChildren('supportsFitToBounds');
}
async isFittable(): Promise<boolean> {
return this._asyncSomeChildren('isFittable');
}
isIncludeInFitToBounds(): boolean {
return this.getChildren().some((child) => {
return child.isIncludeInFitToBounds();
});
}
async isFilteredByGlobalTime(): Promise<boolean> {
return this._asyncSomeChildren('isFilteredByGlobalTime');
}
async getDisplayName(source?: ISource): Promise<string> {
return this.getLabel();
}
async getAttributions(): Promise<Attribution[]> {
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: <EuiIcon size="m" type="layers" />,
tooltipContent: '',
};
}
async hasLegendDetails(): Promise<boolean> {
return this._children.length > 0;
}
renderLegendDetails(): ReactElement<any> | 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<ImmutableSourceProperty[]> {
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<MapExtent | null> {
return getLayersExtent(this.getChildren(), getDataRequestContext);
}
renderStyleEditor(
onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void,
onCustomIconsChange: (customIcons: CustomIcon[]) => void
): ReactElement<any> | 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<LICENSED_FEATURES[]> {
return [];
}
getGeoFieldNames(): string[] {
return [];
}
async getStyleMetaDescriptorFromLocalFeatures(): Promise<StyleMetaDescriptor | null> {
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;
}
}

View file

@ -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');
});

View file

@ -250,8 +250,12 @@ export class BlendedVectorLayer extends GeoJsonVectorLayer implements IVectorLay
return false;
}
async cloneDescriptor(): Promise<VectorLayerDescriptor> {
const clonedDescriptor = await super.cloneDescriptor();
async cloneDescriptor(): Promise<VectorLayerDescriptor[]> {
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 {

View file

@ -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;
}

View file

@ -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(),
});
}

View file

@ -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

View file

@ -162,8 +162,13 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer {
);
}
async cloneDescriptor(): Promise<VectorLayerDescriptor> {
const clonedDescriptor = (await super.cloneDescriptor()) as VectorLayerDescriptor;
async cloneDescriptor(): Promise<VectorLayerDescriptor[]> {
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(),
});

View file

@ -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 ? (
<p>
{i18n.translate('xpack.maps.deleteLayerConfirmModal.multiLayerWarning', {
defaultMessage: `Removing this layer also removes {numChildren} nested {numChildren, plural, one {layer} other {layers}}.`,
values: { numChildren },
})}
</p>
) : null;
}
return (
<EuiConfirmModal
title={i18n.translate('xpack.maps.deleteLayerConfirmModal.title', {
defaultMessage: 'Remove layer?',
})}
onCancel={props.onCancel}
onConfirm={props.onConfirm}
cancelButtonText={i18n.translate('xpack.maps.deleteLayerConfirmModal.cancelButtonText', {
defaultMessage: 'Cancel',
})}
confirmButtonText={i18n.translate('xpack.maps.deleteLayerConfirmModal.confirmButtonText', {
defaultMessage: 'Remove layer',
})}
buttonColor="danger"
defaultFocusedButton="cancel"
>
<EuiText>
{renderMultiLayerWarning()}
<p>
{i18n.translate('xpack.maps.deleteLayerConfirmModal.unrecoverableWarning', {
defaultMessage: `You can't recover removed layers.`,
})}
</p>
</EuiText>
</EuiConfirmModal>
);
}

View file

@ -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<Props, State> {
};
_loadImmutableSourceProperties = async () => {
if (!this.props.selectedLayer) {
if (!this.props.selectedLayer || isLayerGroup(this.props.selectedLayer)) {
return;
}
@ -160,7 +161,11 @@ export class EditLayerPanel extends Component<Props, State> {
}
_renderFilterSection() {
if (!this.props.selectedLayer || !this.props.selectedLayer.supportsElasticsearchFilters()) {
if (
!this.props.selectedLayer ||
isLayerGroup(this.props.selectedLayer) ||
!this.props.selectedLayer.supportsElasticsearchFilters()
) {
return null;
}
@ -197,25 +202,63 @@ export class EditLayerPanel extends Component<Props, State> {
);
}
_renderSourceProperties() {
return this.state.immutableSourceProps.map(
({ label, value, link }: ImmutableSourceProperty) => {
function renderValue() {
if (link) {
return (
<EuiLink href={link} target="_blank">
{value}
</EuiLink>
);
}
return <span>{value}</span>;
}
return (
<p key={label} className="mapLayerPanel__sourceDetail">
<strong>{label}</strong> {renderValue()}
</p>
);
}
_renderSourceDetails() {
return !this.props.selectedLayer || isLayerGroup(this.props.selectedLayer) ? null : (
<div className="mapLayerPanel__sourceDetails">
<EuiAccordion
id="accordion1"
buttonContent={i18n.translate('xpack.maps.layerPanel.sourceDetailsLabel', {
defaultMessage: 'Source details',
})}
>
<EuiText color="subdued" size="s">
<EuiSpacer size="xs" />
{this.state.immutableSourceProps.map(
({ label, value, link }: ImmutableSourceProperty) => {
function renderValue() {
if (link) {
return (
<EuiLink href={link} target="_blank">
{value}
</EuiLink>
);
}
return <span>{value}</span>;
}
return (
<p key={label} className="mapLayerPanel__sourceDetail">
<strong>{label}</strong> {renderValue()}
</p>
);
}
)}
</EuiText>
</EuiAccordion>
</div>
);
}
_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 : (
<StyleSettings />
);
}
@ -224,9 +267,6 @@ export class EditLayerPanel extends Component<Props, State> {
return null;
}
const descriptor = this.props.selectedLayer.getDescriptor() as VectorLayerDescriptor;
const numberOfJoins = descriptor.joins ? descriptor.joins.length : 0;
return (
<KibanaContextProvider
services={{
@ -249,19 +289,7 @@ export class EditLayerPanel extends Component<Props, State> {
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
<div className="mapLayerPanel__sourceDetails">
<EuiAccordion
id="accordion1"
buttonContent={i18n.translate('xpack.maps.layerPanel.sourceDetailsLabel', {
defaultMessage: 'Source details',
})}
>
<EuiText color="subdued" size="s">
<EuiSpacer size="xs" />
{this._renderSourceProperties()}
</EuiText>
</EuiAccordion>
</div>
{this._renderSourceDetails()}
</EuiFlyoutHeader>
<div className="mapLayerPanel__body">
@ -273,19 +301,13 @@ export class EditLayerPanel extends Component<Props, State> {
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()}
<StyleSettings />
{this._renderStyleEditor()}
</div>
</div>

View file

@ -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 = (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="danger"
onClick={removeLayer}
flush="right"
data-test-subj="mapRemoveLayerButton"
>
<FormattedMessage
id="xpack.maps.layerPanel.footer.removeLayerButtonLabel"
defaultMessage="Remove layer"
interface State {
showRemoveModal: boolean;
}
export class FlyoutFooter extends Component<Props, State> {
state: State = {
showRemoveModal: false,
};
_showRemoveModal = () => {
this.setState({ showRemoveModal: true });
};
render() {
const cancelButtonLabel = this.props.hasStateChanged ? (
<FormattedMessage
id="xpack.maps.layerPanel.footer.cancelButtonLabel"
defaultMessage="Cancel"
/>
) : (
<FormattedMessage id="xpack.maps.layerPanel.footer.closeButtonLabel" defaultMessage="Close" />
);
const removeModal =
this.props.selectedLayer && this.state.showRemoveModal ? (
<RemoveLayerConfirmModal
layer={this.props.selectedLayer}
onCancel={() => {
this.setState({ showRemoveModal: false });
}}
onConfirm={() => {
this.setState({ showRemoveModal: false });
this.props.removeLayer();
}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
);
) : null;
const cancelButtonLabel = hasStateChanged ? (
<FormattedMessage id="xpack.maps.layerPanel.footer.cancelButtonLabel" defaultMessage="Cancel" />
) : (
<FormattedMessage id="xpack.maps.layerPanel.footer.closeButtonLabel" defaultMessage="Close" />
);
return (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={cancelLayerPanel}
flush="left"
data-test-subj="layerPanelCancelButton"
>
{cancelButtonLabel}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiSpacer />
</EuiFlexItem>
{removeBtn}
<EuiFlexItem grow={false}>
<EuiButton disabled={!hasStateChanged} iconType="check" onClick={saveLayerEdits} fill>
<FormattedMessage
id="xpack.maps.layerPanel.footer.saveAndCloseButtonLabel"
defaultMessage="Save & close"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
};
return (
<>
{removeModal}
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={this.props.cancelLayerPanel}
flush="left"
data-test-subj="layerPanelCancelButton"
>
{cancelButtonLabel}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiSpacer />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="danger"
onClick={this._showRemoveModal}
flush="right"
data-test-subj="mapRemoveLayerButton"
>
<FormattedMessage
id="xpack.maps.layerPanel.footer.removeLayerButtonLabel"
defaultMessage="Remove layer"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
disabled={!this.props.hasStateChanged}
iconType="check"
onClick={this.props.saveLayerEdits}
fill
>
<FormattedMessage
id="xpack.maps.layerPanel.footer.saveAndCloseButtonLabel"
defaultMessage="Save & close"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
}

View file

@ -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),
};
}

View file

@ -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 : (
<ValidatedDualRange
label={i18n.translate('xpack.maps.layerPanel.settingsPanel.visibleZoomLabel', {
defaultMessage: 'Visibility',
@ -256,10 +257,14 @@ export function LayerSettings(props: Props) {
<EuiSpacer size="m" />
{renderLabel()}
{renderZoomSliders()}
<AlphaSlider alpha={props.layer.getAlpha()} onChange={onAlphaChange} />
{isLayerGroup(props.layer) ? null : (
<AlphaSlider alpha={props.layer.getAlpha()} onChange={onAlphaChange} />
)}
{renderShowLabelsOnTop()}
{renderShowLocaleSelector()}
<AttributionFormRow layer={props.layer} onChange={onAttributionChange} />
{isLayerGroup(props.layer) ? null : (
<AttributionFormRow layer={props.layer} onChange={onAttributionChange} />
)}
{renderIncludeInFitToBounds()}
{renderDisableTooltips()}
</EuiPanel>

View file

@ -1,2 +1,3 @@
@import 'layer_control';
@import 'layer_toc/layer_toc';
@import 'layer_toc/toc_entry/toc_entry';

View file

@ -6,9 +6,12 @@ exports[`LayerTOC is rendered 1`] = `
>
<EuiDragDropContext
onDragEnd={[Function]}
onDragStart={[Function]}
onDragUpdate={[Function]}
>
<EuiDroppable
droppableId="mapLayerTOC"
isCombineEnabled={true}
spacing="none"
>
<Component />
@ -22,19 +25,23 @@ exports[`LayerTOC props isReadOnly 1`] = `
data-test-subj="mapLayerTOC"
>
<TOCEntry
depth={0}
key="2"
layer={
Object {
"getId": [Function],
"getParent": [Function],
"supportsFitToBounds": [Function],
}
}
/>
<TOCEntry
depth={0}
key="1"
layer={
Object {
"getId": [Function],
"getParent": [Function],
"supportsFitToBounds": [Function],
}
}

View file

@ -0,0 +1,11 @@
.mapLayerToc-droppable-dropNotAllowed * {
cursor: not-allowed !important;
}
.mapLayerToc-droppable-isCombining * {
cursor: alias !important;
}
.mapLayerToc-droppable-isDragging * {
cursor: ns-resize !important;
}

View file

@ -9,21 +9,33 @@ import { AnyAction } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { connect } from 'react-redux';
import { LayerTOC } from './layer_toc';
import { updateLayerOrder } from '../../../../actions';
import {
createLayerGroup,
moveLayerToBottom,
moveLayerToLeftOfTarget,
setLayerParent,
} from '../../../../actions';
import { getLayerList } from '../../../../selectors/map_selectors';
import { getIsReadOnly } from '../../../../selectors/ui_selectors';
import { getIsReadOnly, getOpenTOCDetails } from '../../../../selectors/ui_selectors';
import { MapStoreState } from '../../../../reducers/store';
function mapStateToProps(state: MapStoreState) {
return {
isReadOnly: getIsReadOnly(state),
layerList: getLayerList(state),
openTOCDetails: getOpenTOCDetails(state),
};
}
function mapDispatchToProps(dispatch: ThunkDispatch<MapStoreState, void, AnyAction>) {
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)),
};
}

View file

@ -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', () => {

View file

@ -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<Props> {
state: State = {
...CLEAR_DND_STATE,
};
componentWillUnmount() {
this._updateDebounced.cancel();
}
@ -29,60 +52,201 @@ export class LayerTOC extends Component<Props> {
_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;
}
// 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 prevIndex = reverseIndex(source.index);
const newIndex = reverseIndex(destination.index);
const newOrder = [];
for (let i = 0; i < this.props.layerList.length; i++) {
newOrder.push(i);
if (!destination || source.index === destination.index) {
this.setState({ ...CLEAR_DND_STATE });
return;
}
newOrder.splice(prevIndex, 1);
newOrder.splice(newIndex, 0, prevIndex);
this.props.updateLayerOrder(newOrder);
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) : [];
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;
}
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 <TOCEntry key={layer.getId()} layer={layer} />;
return tocEntryList.map(({ depth, layer }) => {
return <TOCEntry key={layer.getId()} depth={depth} layer={layer} />;
});
}
return (
<EuiDragDropContext onDragEnd={this._onDragEnd}>
<EuiDroppable droppableId="mapLayerTOC" spacing="none">
{(droppableProvided, snapshot) => {
const tocEntries = reverseLayerList.map((layer, idx: number) => (
<EuiDragDropContext
onDragStart={this._onDragStart}
onDragUpdate={this._onDragUpdate}
onDragEnd={this._onDragEnd}
>
<EuiDroppable
droppableId="mapLayerTOC"
spacing="none"
isCombineEnabled={!this.state.isOwnAncestor}
className={this._getDroppableClass()}
>
{(droppableProvided, droppableSnapshot) => {
const tocEntries = tocEntryList.map(({ draggableIndex, depth, layer }) => (
<EuiDraggable
spacing="none"
key={layer.getId()}
index={idx}
index={draggableIndex}
draggableId={layer.getId()}
customDragHandle={true}
disableInteractiveElementBlocking // Allows button to be drag handle
>
{(provided, state) => (
<TOCEntry
layer={layer}
dragHandleProps={provided.dragHandleProps}
isDragging={state.isDragging}
isDraggingOver={snapshot.isDraggingOver}
/>
)}
{(draggableProvided, draggableSnapshot) => {
return (
<TOCEntry
depth={depth}
layer={layer}
dragHandleProps={draggableProvided.dragHandleProps}
isDragging={draggableSnapshot.isDragging}
isDraggingOver={droppableSnapshot.isDraggingOver}
/>
);
}}
</EuiDraggable>
));
return <div>{tocEntries}</div>;

View file

@ -5,6 +5,94 @@ exports[`TOCEntry is rendered 1`] = `
className="mapTocEntry"
data-layerid="1"
id="1"
style={Object {}}
>
<div
className="mapTocEntry-visible"
>
<Connect(TOCEntryActionsPopover)
displayName="layer 1"
escapedDisplayName="layer_1"
isEditButtonDisabled={false}
layer={
Object {
"getDisplayName": [Function],
"getId": [Function],
"hasErrors": [Function],
"hasLegendDetails": [Function],
"isPreviewLayer": [Function],
"isVisible": [Function],
"renderLegendDetails": [Function],
"showAtZoomLevel": [Function],
"supportsFitToBounds": [Function],
}
}
openLayerSettings={[Function]}
supportsFitToBounds={true}
/>
<div
className="mapTocEntry__layerIcons"
>
<EuiButtonIcon
aria-label="Hide layer"
iconType="eyeClosed"
key="toggleVisiblity"
onClick={[Function]}
title="Hide layer"
/>
<EuiButtonIcon
aria-label="Fit to data"
iconType="expand"
key="fitToBounds"
onClick={[Function]}
title="Fit to data"
/>
<EuiButtonIcon
aria-label="Edit layer settings"
iconType="pencil"
isDisabled={false}
key="settings"
onClick={[Function]}
title="Edit layer settings"
/>
<EuiButtonIcon
aria-label="Reorder layer"
className="mapTocEntry__grab"
iconType="grab"
key="reorder"
title="Reorder layer"
/>
</div>
</div>
<span
className="mapTocEntry__detailsToggle"
>
<button
aria-label="Show layer details"
className="mapTocEntry__detailsToggleButton"
onClick={[Function]}
title="Show layer details"
>
<EuiIcon
className="eui-alignBaseline"
size="s"
type="arrowDown"
/>
</button>
</span>
</div>
`;
exports[`TOCEntry props Should indent child layer 1`] = `
<div
className="mapTocEntry"
data-layerid="1"
id="1"
style={
Object {
"paddingLeft": "56px",
}
}
>
<div
className="mapTocEntry-visible"
@ -87,6 +175,7 @@ exports[`TOCEntry props Should shade background when not selected layer 1`] = `
className="mapTocEntry"
data-layerid="1"
id="1"
style={Object {}}
>
<div
className="mapTocEntry-visible"
@ -169,6 +258,7 @@ exports[`TOCEntry props Should shade background when selected layer 1`] = `
className="mapTocEntry mapTocEntry-isSelected"
data-layerid="1"
id="1"
style={Object {}}
>
<div
className="mapTocEntry-visible"
@ -251,6 +341,7 @@ exports[`TOCEntry props isReadOnly 1`] = `
className="mapTocEntry"
data-layerid="1"
id="1"
style={Object {}}
>
<div
className="mapTocEntry-visible"
@ -318,6 +409,7 @@ exports[`TOCEntry props should display layer details when isLegendDetailsOpen is
className="mapTocEntry"
data-layerid="1"
id="1"
style={Object {}}
>
<div
className="mapTocEntry-visible"

View file

@ -52,6 +52,7 @@ const mockLayer = {
} as unknown as ILayer;
const defaultProps = {
depth: 0,
layer: mockLayer,
selectedLayer: undefined,
openLayerPanel: async () => {},
@ -93,6 +94,17 @@ describe('TOCEntry', () => {
expect(component).toMatchSnapshot();
});
test('Should indent child layer', async () => {
const component = shallow(<TOCEntry {...defaultProps} depth={2} />);
// 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(<TOCEntry {...defaultProps} isLegendDetailsOpen={true} />);

View file

@ -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<Props, State> {
}
_renderDetailsToggle() {
if (!this.state.hasLegendDetails) {
if (this.props.isDragging || !this.state.hasLegendDetails) {
return null;
}
@ -319,8 +320,12 @@ export class TOCEntry extends Component<Props, State> {
'mapTocEntry-isInEditingMode': this.props.isFeatureEditorOpenForLayer,
});
const depthStyle =
this.props.depth > 0 ? { paddingLeft: `${8 + this.props.depth * 24}px` } : {};
return (
<div
style={depthStyle}
className={classes}
id={this.props.layer.getId()}
data-layerid={this.props.layer.getId()}

View file

@ -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<Props, State> {
state: State = {
isPopoverOpen: false,
showRemoveModal: false,
supportsFeatureEditing: false,
isFeatureEditingEnabled: false,
};
@ -119,10 +122,6 @@ export class TOCEntryActionsPopover extends Component<Props, State> {
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<Props, State> {
toolTipContent: null,
'data-test-subj': 'removeLayerButton',
onClick: () => {
this._closePopover();
this._removeLayer();
this.setState({ showRemoveModal: true });
},
});
}
@ -246,30 +244,46 @@ export class TOCEntryActionsPopover extends Component<Props, State> {
}
render() {
const removeModal = this.state.showRemoveModal ? (
<RemoveLayerConfirmModal
layer={this.props.layer}
onCancel={() => {
this.setState({ showRemoveModal: false });
}}
onConfirm={() => {
this.setState({ showRemoveModal: false });
this._closePopover();
this.props.removeLayer(this.props.layer.getId());
}}
/>
) : null;
return (
<EuiPopover
id={this.props.layer.getId()}
className="mapLayTocActions"
button={
<TOCEntryButton
layer={this.props.layer}
displayName={this.props.displayName}
escapedDisplayName={this.props.escapedDisplayName}
onClick={this._togglePopover}
<>
{removeModal}
<EuiPopover
id={this.props.layer.getId()}
className="mapLayTocActions"
button={
<TOCEntryButton
layer={this.props.layer}
displayName={this.props.displayName}
escapedDisplayName={this.props.escapedDisplayName}
onClick={this._togglePopover}
/>
}
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
panelPaddingSize="none"
anchorPosition="leftUp"
anchorClassName="mapLayTocActions__popoverAnchor"
>
<EuiContextMenu
initialPanelId={0}
panels={[this._getActionsPanel()]}
data-test-subj={`layerTocActionsPanel${this.props.escapedDisplayName}`}
/>
}
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
panelPaddingSize="none"
anchorPosition="leftUp"
anchorClassName="mapLayTocActions__popoverAnchor"
>
<EuiContextMenu
initialPanelId={0}
panels={[this._getActionsPanel()]}
data-test-subj={`layerTocActionsPanel${this.props.escapedDisplayName}`}
/>
</EuiPopover>
</EuiPopover>
</>
);
}
}

View file

@ -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<Props, State> {
}
getIconAndTooltipContent(): IconAndTooltipContent {
let icon;
let tooltipContent = null;
const footnotes = [];
if (this.props.layer.hasErrors()) {
icon = (
<EuiIcon
aria-label={i18n.translate('xpack.maps.layer.loadWarningAriaLabel', {
defaultMessage: 'Load warning',
})}
size="m"
type="alert"
color="warning"
/>
);
tooltipContent = this.props.layer.getErrors();
} else if (!this.props.layer.isVisible()) {
icon = <EuiIcon size="m" type="eyeClosed" />;
tooltipContent = i18n.translate('xpack.maps.layer.layerHiddenTooltip', {
defaultMessage: `Layer is hidden.`,
});
} else if (this.props.layer.isLayerLoading()) {
icon = <EuiLoadingSpinner size="m" />;
} else if (!this.props.layer.showAtZoomLevel(this.props.zoom)) {
return {
icon: (
<EuiIcon
aria-label={i18n.translate('xpack.maps.layer.loadWarningAriaLabel', {
defaultMessage: 'Load warning',
})}
size="m"
type="alert"
color="warning"
/>
),
tooltipContent: this.props.layer.getErrors(),
footnotes: [],
};
}
if (!this.props.layer.isVisible()) {
return {
icon: <EuiIcon size="m" type="eyeClosed" />,
tooltipContent: i18n.translate('xpack.maps.layer.layerHiddenTooltip', {
defaultMessage: `Layer is hidden.`,
}),
footnotes: [],
};
}
if (this.props.layer.isLayerLoading()) {
return {
icon: <EuiLoadingSpinner size="m" />,
tooltipContent: '',
footnotes: [],
};
}
if (!this.props.layer.showAtZoomLevel(this.props.zoom)) {
const minZoom = this.props.layer.getMinZoom();
const maxZoom = this.props.layer.getMaxZoom();
icon = <EuiIcon size="m" type="expand" />;
tooltipContent = i18n.translate('xpack.maps.layer.zoomFeedbackTooltip', {
defaultMessage: `Layer is visible between zoom levels {minZoom} and {maxZoom}.`,
values: { minZoom, maxZoom },
});
} else {
const { icon: layerIcon, tooltipContent: layerTooltipContent } =
this.props.layer.getLayerIcon(true);
icon = layerIcon;
if (layerTooltipContent) {
tooltipContent = layerTooltipContent;
}
return {
icon: <EuiIcon size="m" type="expand" />,
tooltipContent: i18n.translate('xpack.maps.layer.zoomFeedbackTooltip', {
defaultMessage: `Layer is visible between zoom levels {minZoom} and {maxZoom}.`,
values: { minZoom, maxZoom },
}),
footnotes: [],
};
}
if (this.props.isUsingSearch && this.props.layer.getQueryableIndexPatternIds().length) {
footnotes.push({
icon: <EuiIcon color="subdued" type="filter" size="s" />,
message: i18n.translate('xpack.maps.layer.isUsingSearchMsg', {
defaultMessage: 'Results narrowed by global search',
}),
});
}
if (this.state.isFilteredByGlobalTime) {
footnotes.push({
icon: <EuiIcon color="subdued" type="clock" size="s" />,
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: <EuiIcon color="subdued" type="stop" size="s" />,
message: i18n.translate('xpack.maps.layer.isUsingBoundsFilter', {
defaultMessage: 'Results narrowed by visible map area',
}),
});
}
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: <EuiIcon color="subdued" type="filter" size="s" />,
message: i18n.translate('xpack.maps.layer.isUsingSearchMsg', {
defaultMessage: 'Results narrowed by global search',
}),
});
}
if (this.state.isFilteredByGlobalTime) {
footnotes.push({
icon: <EuiIcon color="subdued" type="clock" size="s" />,
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: <EuiIcon color="subdued" type="stop" size="s" />,
message: i18n.translate('xpack.maps.layer.isUsingBoundsFilter', {
defaultMessage: 'Results narrowed by visible map area',
}),
});
}
return {

View file

@ -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),

View file

@ -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<string, ILayer[]>();
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;
}
);

View file

@ -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);
}