mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
97eb9b6163
commit
e45170e50a
37 changed files with 2066 additions and 977 deletions
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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)));
|
||||
};
|
||||
}
|
||||
|
||||
|
|
66
x-pack/plugins/maps/public/actions/get_layers_extent.tsx
Normal file
66
x-pack/plugins/maps/public/actions/get_layers_extent.tsx
Normal 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;
|
||||
}
|
|
@ -24,3 +24,4 @@ export {
|
|||
openOnHoverTooltip,
|
||||
updateOpenTooltips,
|
||||
} from './tooltip_actions';
|
||||
export { getLayersExtent } from './get_layers_extent';
|
||||
|
|
|
@ -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()));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -18,7 +18,7 @@ class MockSource {
|
|||
this._fitToBounds = fitToBounds;
|
||||
}
|
||||
cloneDescriptor() {
|
||||
return {};
|
||||
return [{}];
|
||||
}
|
||||
|
||||
async supportsFitToBounds() {
|
||||
|
|
|
@ -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 || [];
|
||||
}
|
||||
|
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
@import 'layer_control';
|
||||
@import 'layer_toc/layer_toc';
|
||||
@import 'layer_toc/toc_entry/toc_entry';
|
||||
|
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
.mapLayerToc-droppable-dropNotAllowed * {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.mapLayerToc-droppable-isCombining * {
|
||||
cursor: alias !important;
|
||||
}
|
||||
|
||||
.mapLayerToc-droppable-isDragging * {
|
||||
cursor: ns-resize !important;
|
||||
}
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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} />);
|
||||
|
||||
|
|
|
@ -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()}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue