diff --git a/x-pack/plugins/maps/public/shared/components/__snapshots__/layer_toc_actions.test.js.snap b/x-pack/plugins/maps/public/shared/components/__snapshots__/layer_toc_actions.test.js.snap new file mode 100644 index 0000000000000..a5b56da230301 --- /dev/null +++ b/x-pack/plugins/maps/public/shared/components/__snapshots__/layer_toc_actions.test.js.snap @@ -0,0 +1,390 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LayerTocActions is rendered 1`] = ` + +
+ icon mock +
+ + } + closePopover={[Function]} + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} +> + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + }, + ], + "title": "Layer actions", + }, + ] + } + /> +
+`; + +exports[`LayerTocActions should disable fit to data when supportsFitToBounds is false 1`] = ` + +
+ icon mock +
+ + } + closePopover={[Function]} + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} +> + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": "Layer does not support fit to data", + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + }, + ], + "title": "Layer actions", + }, + ] + } + /> +
+`; + +exports[`LayerTocActions should display spinner when layer is loading 1`] = ` + + + + } + closePopover={[Function]} + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} +> + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + }, + ], + "title": "Layer actions", + }, + ] + } + /> + +`; + +exports[`LayerTocActions should provide feedback when layer is not visible because of current zoom level 1`] = ` + + +
+ icon mock +
+
+ + } + closePopover={[Function]} + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} +> + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + }, + ], + "title": "Layer actions", + }, + ] + } + /> +
+`; + +exports[`LayerTocActions should show visible toggle when layer is not visible 1`] = ` + +
+ icon mock +
+ + } + closePopover={[Function]} + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} +> + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Show layer", + "onClick": [Function], + }, + ], + "title": "Layer actions", + }, + ] + } + /> +
+`; + +exports[`LayerTocActions should show warning when layer has errors 1`] = ` + + + + } + closePopover={[Function]} + hasArrow={true} + id="contextMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + withTitle={true} +> + , + "name": "Fit to data", + "onClick": [Function], + "toolTipContent": null, + }, + Object { + "data-test-subj": "layerVisibilityToggleButton", + "icon": , + "name": "Hide layer", + "onClick": [Function], + }, + ], + "title": "Layer actions", + }, + ] + } + /> + +`; diff --git a/x-pack/plugins/maps/public/shared/components/layer_toc_actions.js b/x-pack/plugins/maps/public/shared/components/layer_toc_actions.js index 69bc14fa35bc0..ae71f5db63469 100644 --- a/x-pack/plugins/maps/public/shared/components/layer_toc_actions.js +++ b/x-pack/plugins/maps/public/shared/components/layer_toc_actions.js @@ -41,9 +41,26 @@ function cleanDisplayName(displayName) { export class LayerTocActions extends Component { state = { - isPopoverOpen: false + isPopoverOpen: false, + supportsFitToBounds: false, }; + componentDidMount() { + this._isMounted = true; + this._loadSupportsFitToBounds(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadSupportsFitToBounds() { + const supportsFitToBounds = await this.props.layer.supportsFitToBounds(); + if (this._isMounted) { + this.setState({ supportsFitToBounds }); + } + } + _onClick = () => { this.setState(prevState => ({ isPopoverOpen: !prevState.isPopoverOpen, @@ -128,6 +145,9 @@ export class LayerTocActions extends Component { size="m" /> ), + 'data-test-subj': 'fitToBoundsButton', + toolTipContent: this.state.supportsFitToBounds ? null : 'Layer does not support fit to data', + disabled: !this.state.supportsFitToBounds, onClick: () => { this._closePopover(); this.props.fitToBounds(); diff --git a/x-pack/plugins/maps/public/shared/components/layer_toc_actions.test.js b/x-pack/plugins/maps/public/shared/components/layer_toc_actions.test.js new file mode 100644 index 0000000000000..118f6b20f23a2 --- /dev/null +++ b/x-pack/plugins/maps/public/shared/components/layer_toc_actions.test.js @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { LayerTocActions } from './layer_toc_actions'; + +let supportsFitToBounds; +let isLayerLoading; +let isVisible; +let hasErrors; +let showAtZoomLevel; +const layerMock = { + supportsFitToBounds: () => { return supportsFitToBounds; }, + isVisible: () => { return isVisible; }, + hasErrors: () => { return hasErrors; }, + getErrors: () => { return 'simulated layer error'; }, + isLayerLoading: () => { return isLayerLoading; }, + showAtZoomLevel: () => { return showAtZoomLevel; }, + getZoomConfig: () => { return { minZoom: 2, maxZoom: 3 }; }, + getIcon: () => { return (
icon mock
); }, +}; + +const defaultProps = { + displayName: 'layer1', + zoom: 0, + layer: layerMock, +}; + +describe('LayerTocActions', () => { + beforeEach(() => { + supportsFitToBounds = true; + isLayerLoading = false; + isVisible = true; + hasErrors = false; + showAtZoomLevel = true; + }); + + test('is rendered', async () => { + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component) + .toMatchSnapshot(); + }); + + test('should disable fit to data when supportsFitToBounds is false', async () => { + supportsFitToBounds = false; + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component) + .toMatchSnapshot(); + }); + + test('should display spinner when layer is loading', async () => { + isLayerLoading = true; + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component) + .toMatchSnapshot(); + }); + + test('should show warning when layer has errors', async () => { + hasErrors = true; + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component) + .toMatchSnapshot(); + }); + + test('should show visible toggle when layer is not visible', async () => { + isVisible = false; + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component) + .toMatchSnapshot(); + }); + + test('should provide feedback when layer is not visible because of current zoom level', async () => { + showAtZoomLevel = false; + const component = shallow( + + ); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component) + .toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/shared/layers/layer.js b/x-pack/plugins/maps/public/shared/layers/layer.js index 8d095d17e0be1..945176e6eb04f 100644 --- a/x-pack/plugins/maps/public/shared/layers/layer.js +++ b/x-pack/plugins/maps/public/shared/layers/layer.js @@ -53,6 +53,10 @@ export class AbstractLayer { return this._source.isJoinable(); } + async supportsFitToBounds() { + return await this._source.supportsFitToBounds(); + } + async getDisplayName() { if (this._descriptor.label) { return this._descriptor.label; diff --git a/x-pack/plugins/maps/public/shared/layers/sources/es_source.js b/x-pack/plugins/maps/public/shared/layers/sources/es_source.js index 02718133a7efe..a3a4c7356ddad 100644 --- a/x-pack/plugins/maps/public/shared/layers/sources/es_source.js +++ b/x-pack/plugins/maps/public/shared/layers/sources/es_source.js @@ -78,7 +78,7 @@ export class AbstractESSource extends AbstractVectorSource { return searchSource; } - async getBoundsForFilters({ query, timeFilters }, layerName) { + async getBoundsForFilters({ query, timeFilters }) { const searchSource = await this._makeSearchSource({ query, timeFilters }, 0); const geoField = await this._getGeoField(); @@ -96,15 +96,29 @@ export class AbstractESSource extends AbstractVectorSource { const aggConfigs = new AggConfigs(indexPattern, geoBoundsAgg); searchSource.setField('aggs', aggConfigs.toDsl()); - const esResp = await this._runEsQuery(layerName, searchSource, 'bounds request'); - const esBounds = _.get(esResp, 'aggregations.1.bounds'); - return (esBounds) ? - { - min_lon: esBounds.top_left.lon, - max_lon: esBounds.bottom_right.lon, - min_lat: esBounds.bottom_right.lat, - max_lat: esBounds.top_left.lat - } : null; + let esBounds; + try { + const esResp = await searchSource.fetch(); + esBounds = _.get(esResp, 'aggregations.1.bounds'); + } catch(error) { + esBounds = { + top_left: { + lat: 90, + lon: -180 + }, + bottom_right: { + lat: -90, + lon: 180 + } + }; + } + + return { + min_lon: esBounds.top_left.lon, + max_lon: esBounds.bottom_right.lon, + min_lat: esBounds.bottom_right.lat, + max_lat: esBounds.top_left.lat + }; } async isTimeAware() { @@ -130,6 +144,18 @@ export class AbstractESSource extends AbstractVectorSource { } } + async supportsFitToBounds() { + try { + const geoField = await this._getGeoField(); + // geo_bounds aggregation only supports geo_point + // there is currently no backend support for getting bounding box of geo_shape field + return geoField.type !== 'geo_shape'; + } catch (error) { + return false; + } + } + + async _getGeoField() { const indexPattern = await this._getIndexPattern(); const geoField = indexPattern.fields.byName[this._descriptor.geoField]; diff --git a/x-pack/plugins/maps/public/shared/layers/sources/source.js b/x-pack/plugins/maps/public/shared/layers/sources/source.js index d163a8dcca8cc..faf9280089e80 100644 --- a/x-pack/plugins/maps/public/shared/layers/sources/source.js +++ b/x-pack/plugins/maps/public/shared/layers/sources/source.js @@ -24,6 +24,10 @@ export class AbstractSource { destroy() {} + async supportsFitToBounds() { + return true; + } + /** * return list of immutable source properties. * Immutable source properties are properties that can not be edited by the user. diff --git a/x-pack/test/functional/apps/maps/es_search_source.js b/x-pack/test/functional/apps/maps/es_search_source.js index 2e1dc53c7bd09..079eb24d86612 100644 --- a/x-pack/test/functional/apps/maps/es_search_source.js +++ b/x-pack/test/functional/apps/maps/es_search_source.js @@ -39,9 +39,16 @@ export default function ({ getPageObjects, getService }) { expect(beforeRefreshTimerTimestamp).not.to.equal(afterRefreshTimerTimestamp); }); + describe('inspector', () => { + it('should register elasticsearch request in inspector', async () => { + const hits = await getHits(); + expect(hits).to.equal('6'); + }); + }); + describe('query bar', () => { before(async () => { - await PageObjects.maps.setAndSubmitQuery('machine.os.raw : "win 8"'); + await PageObjects.maps.setAndSubmitQuery('machine.os.raw : "win 8" OR machine.os.raw : "ios"'); }); after(async () => { @@ -53,7 +60,7 @@ export default function ({ getPageObjects, getService }) { const requestStats = await inspector.getTableData(); const hits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits'); await inspector.close(); - expect(hits).to.equal('1'); + expect(hits).to.equal('3'); }); it('should re-fetch query when "refresh" is clicked', async () => { @@ -62,12 +69,15 @@ export default function ({ getPageObjects, getService }) { const afterQueryRefreshTimestamp = await getRequestTimestamp(); expect(beforeQueryRefreshTimestamp).not.to.equal(afterQueryRefreshTimestamp); }); - }); - describe('inspector', () => { - it('should register elasticsearch request in inspector', async () => { - const hits = await getHits(); - expect(hits).to.equal('6'); + it('should apply query to fit to bounds', async () => { + // Set view to other side of world so no matching results + await PageObjects.maps.setView(-15, -100, 6); + await PageObjects.maps.clickFitToBounds('logstash'); + const { lat, lon, zoom } = await PageObjects.maps.getView(); + expect(Math.round(lat)).to.equal(41); + expect(Math.round(lon)).to.equal(-102); + expect(Math.round(zoom)).to.equal(5); }); }); diff --git a/x-pack/test/functional/es_archives/maps/kibana/data.json b/x-pack/test/functional/es_archives/maps/kibana/data.json index b7c6299f73915..610e6ec4a2c0a 100644 --- a/x-pack/test/functional/es_archives/maps/kibana/data.json +++ b/x-pack/test/functional/es_archives/maps/kibana/data.json @@ -107,7 +107,7 @@ "title" : "document example", "description" : "", "mapStateJSON" : "{\"zoom\":4.1,\"center\":{\"lon\":-100.61091,\"lat\":33.23887},\"timeFilters\":{\"from\":\"2015-09-20T00:00:00.000Z\",\"to\":\"2015-09-20T01:00:00.000Z\"},\"refreshConfig\":{\"isPaused\":true,\"interval\":1000}}", - "layerListJSON" : "[{\"id\":\"0hmz5\",\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"road_map\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"TILE\",\"properties\":{\"alphaValue\":1}},\"type\":\"TILE\",\"minZoom\":0,\"maxZoom\":24},{\"id\":\"z52lq\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"sourceDescriptor\":{\"id\":\"e1a5e1a6-676c-4a89-8ea9-0d91d64b73c6\",\"type\":\"ES_SEARCH\",\"indexPatternId\":\"c698b940-e149-11e8-a35a-370a8516603a\",\"geoField\":\"geo.coordinates\",\"limit\":2048,\"filterByMapBounds\":true,\"showTooltip\":true,\"tooltipProperties\":[]},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"alphaValue\":1},\"previousStyle\":null},\"type\":\"VECTOR\"}]", + "layerListJSON" : "[{\"id\":\"0hmz5\",\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"road_map\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"TILE\",\"properties\":{\"alphaValue\":1}},\"type\":\"TILE\",\"minZoom\":0,\"maxZoom\":24},{\"id\":\"z52lq\",\"label\":\"logstash\",\"minZoom\":0,\"maxZoom\":24,\"sourceDescriptor\":{\"id\":\"e1a5e1a6-676c-4a89-8ea9-0d91d64b73c6\",\"type\":\"ES_SEARCH\",\"indexPatternId\":\"c698b940-e149-11e8-a35a-370a8516603a\",\"geoField\":\"geo.coordinates\",\"limit\":2048,\"filterByMapBounds\":true,\"showTooltip\":true,\"tooltipProperties\":[]},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"alphaValue\":1},\"previousStyle\":null},\"type\":\"VECTOR\"}]", "uiStateJSON" : "{\"isDarkMode\":false}", "bounds" : { "type" : "envelope", @@ -390,4 +390,4 @@ } } } -} +} \ No newline at end of file diff --git a/x-pack/test/functional/page_objects/gis_page.js b/x-pack/test/functional/page_objects/gis_page.js index 43d8fae38f117..cf5bdc1b7e1b3 100644 --- a/x-pack/test/functional/page_objects/gis_page.js +++ b/x-pack/test/functional/page_objects/gis_page.js @@ -175,6 +175,13 @@ export function GisPageProvider({ getService, getPageObjects }) { await testSubjects.click('layerVisibilityToggleButton'); } + async clickFitToBounds(layerName) { + log.debug(`Fit to bounds, layer: ${layerName}`); + await this.openLayerTocActionsPanel(layerName); + await testSubjects.click('fitToBoundsButton'); + await this.waitForLayersToLoad(); + } + async openLayerTocActionsPanel(layerName) { const cleanLayerName = layerName.split(' ').join(''); const isOpen = await testSubjects.exists(`layerTocActionsPanel${cleanLayerName}`);