diff --git a/x-pack/legacy/plugins/maps/public/components/map/__snapshots__/feature_properties.test.js.snap b/x-pack/legacy/plugins/maps/public/components/map/__snapshots__/feature_properties.test.js.snap new file mode 100644 index 0000000000000..bdcc13a872b5e --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/components/map/__snapshots__/feature_properties.test.js.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeatureProperties should not show filter button 1`] = ` + + + + + + + + + +
+ prop1 + +
+ prop2 + +
+`; + +exports[`FeatureProperties should show error message if unable to load tooltip content 1`] = ` + +

+ Simulated load properties error +

+
+`; + +exports[`FeatureProperties should show only filter button for filterable properties 1`] = ` + + + + + + + + + + +
+ prop1 + + + +
+ prop2 + +
+`; diff --git a/x-pack/legacy/plugins/maps/public/components/map/__snapshots__/feature_tooltip.test.js.snap b/x-pack/legacy/plugins/maps/public/components/map/__snapshots__/feature_tooltip.test.js.snap index 36f2ef7465fad..fa9511840f253 100644 --- a/x-pack/legacy/plugins/maps/public/components/map/__snapshots__/feature_tooltip.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/components/map/__snapshots__/feature_tooltip.test.js.snap @@ -1,176 +1,210 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FeatureTooltip should not show close button and not show filter button 1`] = ` +exports[`FeatureTooltip (multi) should not show close button / should show count 1`] = ` - + + - -
+ + + + 1 + + of + + 3 + + + + +
`; -exports[`FeatureTooltip should show both filter buttons and close button 1`] = ` +exports[`FeatureTooltip (multi) should show close button / should show count / should show arrows / should show layer filter 1`] = ` - - - - + + + + + + - - - - - - + - - - -
- foo - - - -
- foo - -
+ + 1 + + of + + 3 + + + + + + +
`; -exports[`FeatureTooltip should show close button, but not filter button 1`] = ` +exports[`FeatureTooltip (multi) should show close button / should show count 1`] = ` - - - - + + + + + + - -
+ + + + 1 + + of + + 3 + + + + + + +
`; -exports[`FeatureTooltip should show error message if unable to load tooltip content 1`] = ` - -

- Simulated load properties error -

-
+exports[`FeatureTooltip (single) should not show close button 1`] = ` + + + `; -exports[`FeatureTooltip should show only filter button for filterable properties 1`] = ` +exports[`FeatureTooltip (single) should show close button 1`] = ` - - - - - - - - - - -
- foo - - - -
- foo - -
+ + + + + +
`; diff --git a/x-pack/legacy/plugins/maps/public/components/map/_feature_tooltip.scss b/x-pack/legacy/plugins/maps/public/components/map/_feature_tooltip.scss index 416ba8c6c01f0..1ce7738f37fda 100644 --- a/x-pack/legacy/plugins/maps/public/components/map/_feature_tooltip.scss +++ b/x-pack/legacy/plugins/maps/public/components/map/_feature_tooltip.scss @@ -1,4 +1,5 @@ .mapFeatureTooltip_table { + width: 100%; td { padding: $euiSizeXS; } diff --git a/x-pack/legacy/plugins/maps/public/components/map/feature_properties.js b/x-pack/legacy/plugins/maps/public/components/map/feature_properties.js new file mode 100644 index 0000000000000..079684692e23f --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/components/map/feature_properties.js @@ -0,0 +1,175 @@ +/* + * 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 { + EuiCallOut, + EuiLoadingSpinner, + EuiTextAlign, + EuiButtonIcon +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + + +export class FeatureProperties extends React.Component { + + state = { + properties: null, + loadPropertiesErrorMsg: null, + }; + + componentDidMount() { + this._isMounted = true; + this.prevLayerId = undefined; + this.prevFeatureId = undefined; + this._loadProperties(); + } + + componentDidUpdate() { + this._loadProperties(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + _loadProperties = () => { + this._fetchProperties({ + nextFeatureId: this.props.featureId, + nextLayerId: this.props.layerId + }); + }; + + _fetchProperties = async ({ nextLayerId, nextFeatureId }) => { + if (this.prevLayerId === nextLayerId && this.prevFeatureId === nextFeatureId) { + // do not reload same feature properties + return; + } + + this.prevLayerId = nextLayerId; + this.prevFeatureId = nextFeatureId; + this.setState({ + properties: undefined, + loadPropertiesErrorMsg: undefined, + }); + + let properties; + try { + properties = await this.props.loadFeatureProperties({ layerId: nextLayerId, featureId: nextFeatureId }); + } catch (error) { + if (this._isMounted) { + this.setState({ + properties: [], + loadPropertiesErrorMsg: error.message + }); + } + return; + } + + if (this.prevLayerId !== nextLayerId && this.prevFeatureId !== nextFeatureId) { + // ignore results for old request + return; + } + + if (this._isMounted) { + this.setState({ + properties + }); + } + }; + + + _renderFilterCell(tooltipProperty) { + if (!this.props.showFilterButtons || !tooltipProperty.isFilterable()) { + return null; + } + + return ( + + { + this.props.onCloseTooltip(); + const filterAction = tooltipProperty.getFilterAction(); + filterAction(); + }} + aria-label={i18n.translate('xpack.maps.tooltip.filterOnPropertyAriaLabel', { + defaultMessage: 'Filter on property' + })} + data-test-subj="mapTooltipCreateFilterButton" + /> + + ); + } + + render() { + + if (this.state.loadPropertiesErrorMsg) { + return ( + +

+ {this.state.loadPropertiesErrorMsg} +

+
+ ); + } + + if (!this.state.properties) { + const loadingMsg = i18n.translate('xpack.maps.tooltip.loadingMsg', { + defaultMessage: 'Loading' + }); + return ( + + + {loadingMsg} + + ); + } + + const rows = this.state.properties.map(tooltipProperty => { + const label = tooltipProperty.getPropertyName(); + return ( + + + {label} + + + {this._renderFilterCell(tooltipProperty)} + + ); + }); + + return ( + + + {rows} + +
+ ); + } + +} + diff --git a/x-pack/legacy/plugins/maps/public/components/map/feature_properties.test.js b/x-pack/legacy/plugins/maps/public/components/map/feature_properties.test.js new file mode 100644 index 0000000000000..aa39ad85bd820 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/components/map/feature_properties.test.js @@ -0,0 +1,106 @@ +/* + * 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 { FeatureProperties } from './feature_properties'; + +class MockTooltipProperty { + constructor(key, value, isFilterable) { + this._key = key; + this._value = value; + this._isFilterable = isFilterable; + } + + isFilterable() { + return this._isFilterable; + } + + getFilterAction() { + return () => {}; + } + + getHtmlDisplayValue() { + return this._value; + } + + getPropertyName() { + return this._key; + } +} + +const defaultProps = { + loadFeatureProperties: () => { return []; }, + featureId: `feature`, + layerId: `layer`, + onCloseTooltip: () => {}, + showFilterButtons: false +}; + +const mockTooltipProperties = [ + new MockTooltipProperty('prop1', 'foobar1', true), + new MockTooltipProperty('prop2', 'foobar2', false) +]; + +describe('FeatureProperties', async () => { + + test('should not show filter button', async () => { + const component = shallow( + { return mockTooltipProperties; }} + /> + ); + + // 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 only filter button for filterable properties', async () => { + const component = shallow( + { return mockTooltipProperties; }} + /> + ); + + // 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 error message if unable to load tooltip content', async () => { + const component = shallow( + { throw new Error('Simulated load properties error'); }} + /> + ); + + // 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/legacy/plugins/maps/public/components/map/feature_tooltip.js b/x-pack/legacy/plugins/maps/public/components/map/feature_tooltip.js index b4749ab4e56a4..6c9710b661840 100644 --- a/x-pack/legacy/plugins/maps/public/components/map/feature_tooltip.js +++ b/x-pack/legacy/plugins/maps/public/components/map/feature_tooltip.js @@ -7,191 +7,279 @@ import React, { Fragment } from 'react'; import { EuiButtonIcon, - EuiCallOut, - EuiLoadingSpinner, - EuiTextAlign, + EuiText, + EuiPagination, + EuiSelect, + EuiIconTip, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FeatureProperties } from './feature_properties'; +const ALL_LAYERS = '_ALL_LAYERS_'; +const DEFAULT_PAGE_NUMBER = 0; + export class FeatureTooltip extends React.Component { state = { - properties: undefined, - loadPropertiesErrorMsg: undefined, + uniqueLayers: [], + pageNumber: DEFAULT_PAGE_NUMBER, + layerIdFilter: ALL_LAYERS }; + constructor() { + super(); + this._prevFeatures = null; + } + componentDidMount() { this._isMounted = true; - this.prevLayerId = undefined; - this.prevFeatureId = undefined; - this._loadProperties(); } componentDidUpdate() { - this._loadProperties(); + this._loadUniqueLayers(); } componentWillUnmount() { this._isMounted = false; } - _loadProperties = () => { - this._fetchProperties({ - nextFeatureId: this.props.tooltipState.featureId, - nextLayerId: this.props.tooltipState.layerId, - }); - } + _onLayerChange = (e) => { - _fetchProperties = async ({ nextLayerId, nextFeatureId }) => { - if (this.prevLayerId === nextLayerId && this.prevFeatureId === nextFeatureId) { - // do not reload same feature properties + const layerId = e.target.value; + if (this.state.layerIdFilter === layerId) { return; } - this.prevLayerId = nextLayerId; - this.prevFeatureId = nextFeatureId; this.setState({ - properties: undefined, - loadPropertiesErrorMsg: undefined, + pageNumber: DEFAULT_PAGE_NUMBER, + layerIdFilter: layerId }); + }; - let properties; - try { - properties = await this.props.loadFeatureProperties({ layerId: nextLayerId, featureId: nextFeatureId }); - } catch(error) { - if (this._isMounted) { - this.setState({ - properties: [], - loadPropertiesErrorMsg: error.message - }); - } + _onCloseTooltip = () => { + this.setState({ + layerIdFilter: ALL_LAYERS, + pageNumber: DEFAULT_PAGE_NUMBER + }, () => { + this.props.closeTooltip(); + }); + }; + + _loadUniqueLayers = async () => { + + if (this._prevFeatures === this.props.features) { return; } - if (this.prevLayerId !== nextLayerId && this.prevFeatureId !== nextFeatureId) { - // ignore results for old request - return; + this._prevFeatures = this.props.features; + + + const countByLayerId = new Map(); + for (let i = 0; i < this.props.features.length; i++) { + let count = countByLayerId.get(this.props.features[i].layerId); + if (!count) { + count = 0; + } + count++; + countByLayerId.set(this.props.features[i].layerId, count); } + const layers = []; + countByLayerId.forEach((count, layerId) => { + layers.push(this.props.findLayerById(layerId)); + }); + + const layerNamePromises = layers.map(layer => { + return layer.getDisplayName(); + }); + + const layerNames = await Promise.all(layerNamePromises); + const options = layers.map((layer, index) => { + return { + displayName: layerNames[index], + id: layer.getId(), + count: countByLayerId.get(layer.getId()) + }; + }); + if (this._isMounted) { this.setState({ - properties + uniqueLayers: options, + layerIdFilter: ALL_LAYERS, + pageNumber: DEFAULT_PAGE_NUMBER }); } + }; + + + _renderProperties(features) { + const feature = features[this.state.pageNumber]; + if (!feature) { + return null; + } + return ( + + ); } - _renderFilterCell(tooltipProperty) { - if (!this.props.showFilterButtons || !tooltipProperty.isFilterable()) { + _renderLayerFilterBox() { + if (!this.state.uniqueLayers || this.state.uniqueLayers.length < 2) { return null; } + const layerOptions = this.state.uniqueLayers.map(({ id, displayName, count }) => { + return { + value: id, + text: `(${count}) ${displayName}` + }; + }); + + const options = [ + { + value: ALL_LAYERS, + text: i18n.translate('xpack.maps.tooltip.allLayersLabel', { + defaultMessage: 'All layers' + }) + }, + ...layerOptions + ]; return ( - - { - this.props.closeTooltip(); - const filterAction = tooltipProperty.getFilterAction(); - filterAction(); - }} - aria-label={i18n.translate('xpack.maps.tooltip.filterOnPropertyAriaLabel', { - defaultMessage: 'Filter on property' - })} - data-test-subj="mapTooltipCreateFilterButton" - /> - + ); } - _renderProperties() { - const rows = this.state.properties.map(tooltipProperty => { - const label = tooltipProperty.getPropertyName(); - return ( - - - {label} - - - {this._renderFilterCell(tooltipProperty)} - - ); - }); + _renderHeader() { + + if (!this.props.isLocked) { + return null; + } + const divider = (this.state.uniqueLayers && this.state.uniqueLayers.length > 1) ? + : null; return ( - - - {rows} - -
+ + + + {this._renderLayerFilterBox()} + + + {this._renderCloseButton()} + + + {divider} + ); } - _renderCloseButton() { - if (!this.props.showCloseButton) { + _renderFooter(filteredFeatures) { + + if (filteredFeatures.length === 1) { return null; } + return ( - - - + + + {this._renderPagination(filteredFeatures)} + ); } - render() { - if (!this.state.properties) { - const loadingMsg = i18n.translate('xpack.maps.tooltip.loadingMsg', { - defaultMessage: 'Loading' - }); - return ( - - - {loadingMsg} - - ); + _renderCloseButton() { + return ( + + ); + } + + + _onPageChange = (pageNumber) => { + this.setState({ + pageNumber: pageNumber, + }); + }; + + _filterFeatures() { + if (this.state.layerIdFilter === ALL_LAYERS) { + return this.props.features; } - if (this.state.loadPropertiesErrorMsg) { - return ( - { + return feature.layerId === this.state.layerIdFilter; + }); + } + + _renderPagination(filteredFeatures) { + + const pageNumberReadout = ( + {(this.state.pageNumber + 1)} of {filteredFeatures.length} + ); + + const cycleArrows = (this.props.isLocked) ? () : null; + + const hint = (this.props.isLocked && filteredFeatures.length > 20) ? ( + + -

- {this.state.loadPropertiesErrorMsg} -

-
- ); - } + /> + + ) : null; return ( - {this._renderCloseButton()} - {this._renderProperties()} + + {hint} + + {pageNumberReadout} + + + {cycleArrows} + + + + ); + + } + + render() { + const filteredFeatures = this._filterFeatures(); + return ( + + {this._renderHeader()} + {this._renderProperties(filteredFeatures)} + {this._renderFooter(filteredFeatures)} ); } diff --git a/x-pack/legacy/plugins/maps/public/components/map/feature_tooltip.test.js b/x-pack/legacy/plugins/maps/public/components/map/feature_tooltip.test.js index 1abb22fe93521..328402957bd42 100644 --- a/x-pack/legacy/plugins/maps/public/components/map/feature_tooltip.test.js +++ b/x-pack/legacy/plugins/maps/public/components/map/feature_tooltip.test.js @@ -8,53 +8,57 @@ import React from 'react'; import { shallow } from 'enzyme'; import { FeatureTooltip } from './feature_tooltip'; -class MockTooltipProperty { - constructor(key, value, isFilterable) { - this._key = key; - this._value = value; - this._isFilterable = isFilterable; - } +class MockLayer { - isFilterable() { - return this._isFilterable; + constructor(id) { + this._id = id; } - - getFilterAction() { - return () => {}; + async getDisplayName() { + return `display + ${this._id}`; } - getHtmlDisplayValue() { - return this._value; +} + + +const MULTI_FEATURE_MULTI_LAYER = [ + { + 'id': 'feature1', + 'layerId': 'layer1' + }, + { + 'id': 'feature2', + 'layerId': 'layer1' + }, + { + 'id': 'feature1', + 'layerId': 'layer2' } +]; - getPropertyName() { - return this._key; +const SINGLE_FEATURE = [ + { + 'id': 'feature1', + 'layerId': 'layer1' } -} +]; const defaultProps = { loadFeatureProperties: () => { return []; }, - tooltipState: { - layerId: 'layer1', - featureId: 'feature1', + findLayerById: (id) => { + return new MockLayer(id); }, closeTooltip: () => {}, showFilterButtons: false, - showCloseButton: false + isLocked: false }; +describe('FeatureTooltip (single)', async () => { -const mockTooltipProperties = [ - new MockTooltipProperty('foo', 'bar', true), - new MockTooltipProperty('foo', 'bar', false) -]; - -describe('FeatureTooltip', async () => { - - test('should not show close button and not show filter button', async () => { + test('should not show close button', async () => { const component = shallow( ); @@ -67,11 +71,12 @@ describe('FeatureTooltip', async () => { .toMatchSnapshot(); }); - test('should show close button, but not filter button', async () => { + test('should show close button', async () => { const component = shallow( ); @@ -84,12 +89,15 @@ describe('FeatureTooltip', async () => { .toMatchSnapshot(); }); - test('should show only filter button for filterable properties', async () => { +}); + +describe('FeatureTooltip (multi)', async () => { + + test('should not show close button / should show count', async () => { const component = shallow( { return mockTooltipProperties; }} + features={MULTI_FEATURE_MULTI_LAYER} /> ); @@ -102,13 +110,12 @@ describe('FeatureTooltip', async () => { .toMatchSnapshot(); }); - test('should show both filter buttons and close button', async () => { + test('should show close button / should show count', async () => { const component = shallow( { return mockTooltipProperties; }} + isLocked={true} + features={MULTI_FEATURE_MULTI_LAYER} /> ); @@ -121,13 +128,12 @@ describe('FeatureTooltip', async () => { .toMatchSnapshot(); }); - test('should show error message if unable to load tooltip content', async () => { + test('should show close button / should show count / should show arrows / should show layer filter', async () => { const component = shallow( { throw new Error('Simulated load properties error'); }} + isLocked={true} + features={MULTI_FEATURE_MULTI_LAYER} /> ); @@ -141,4 +147,5 @@ describe('FeatureTooltip', async () => { }); + }); diff --git a/x-pack/legacy/plugins/maps/public/components/map/mb/view.js b/x-pack/legacy/plugins/maps/public/components/map/mb/view.js index b1191e59c1107..8267c18e244b7 100644 --- a/x-pack/legacy/plugins/maps/public/components/map/mb/view.js +++ b/x-pack/legacy/plugins/maps/public/components/map/mb/view.js @@ -126,6 +126,15 @@ export class MBMapContainer extends React.Component { } }, 256); + _getIdsForFeatures(mbFeatures) { + return mbFeatures.map((mbFeature) => { + const layer = this._getLayerByMbLayerId(mbFeature.layer.id); + return { + id: mbFeature.properties[FEATURE_ID_PROPERTY_NAME], + layerId: layer.getId() + }; + }); + } _lockTooltip = (e) => { @@ -136,19 +145,19 @@ export class MBMapContainer extends React.Component { this._updateHoverTooltipState.cancel();//ignore any possible moves - const features = this._getFeaturesUnderPointer(e.point); - if (!features.length) { + const mbFeatures = this._getFeaturesUnderPointer(e.point); + if (!mbFeatures.length) { this.props.setTooltipState(null); return; } - const targetFeature = features[0]; - const layer = this._getLayerByMbLayerId(targetFeature.layer.id); - const popupAnchorLocation = this._justifyAnchorLocation(e.lngLat, targetFeature); + const targetMbFeataure = mbFeatures[0]; + const popupAnchorLocation = this._justifyAnchorLocation(e.lngLat, targetMbFeataure); + + const features = this._getIdsForFeatures(mbFeatures); this.props.setTooltipState({ type: TOOLTIP_TYPE.LOCKED, - layerId: layer.getId(), - featureId: targetFeature.properties[FEATURE_ID_PROPERTY_NAME], + features: features, location: popupAnchorLocation }); }; @@ -165,27 +174,25 @@ export class MBMapContainer extends React.Component { return; } - const features = this._getFeaturesUnderPointer(e.point); - if (!features.length) { + const mbFeatures = this._getFeaturesUnderPointer(e.point); + if (!mbFeatures.length) { this.props.setTooltipState(null); return; } - const targetFeature = features[0]; - + const targetMbFeature = mbFeatures[0]; if (this.props.tooltipState) { - if (targetFeature.properties[FEATURE_ID_PROPERTY_NAME] === this.props.tooltipState.featureId) { + const firstFeature = this.props.tooltipState.features[0]; + if (targetMbFeature.properties[FEATURE_ID_PROPERTY_NAME] === firstFeature.id) { return; } } - const layer = this._getLayerByMbLayerId(targetFeature.layer.id); - const popupAnchorLocation = this._justifyAnchorLocation(e.lngLat, targetFeature); - + const popupAnchorLocation = this._justifyAnchorLocation(e.lngLat, targetMbFeature); + const features = this._getIdsForFeatures(mbFeatures); this.props.setTooltipState({ type: TOOLTIP_TYPE.HOVER, - featureId: targetFeature.properties[FEATURE_ID_PROPERTY_NAME], - layerId: layer.getId(), + features: features, location: popupAnchorLocation }); @@ -215,8 +222,8 @@ export class MBMapContainer extends React.Component { }, []); - //ensure all layers that are actually on the map - //the raw list may contain layer-ids that have not been added to the map yet. + //Ensure that all layers are actually on the map. + //The raw list may contain layer-ids that have not been added to the map yet. //For example: //a vector or heatmap layer will not add a source and layer to the mapbox-map, until that data is available. //during that data-fetch window, the app should not query for layers that do not exist. @@ -386,11 +393,12 @@ export class MBMapContainer extends React.Component { const isLocked = this.props.tooltipState.type === TOOLTIP_TYPE.LOCKED; ReactDOM.render(( ), this._tooltipContainer); @@ -400,9 +408,7 @@ export class MBMapContainer extends React.Component { } _loadFeatureProperties = async ({ layerId, featureId }) => { - const tooltipLayer = this.props.layerList.find(layer => { - return layer.getId() === layerId; - }); + const tooltipLayer = this._findLayerById(layerId); if (!tooltipLayer) { return []; } @@ -411,7 +417,13 @@ export class MBMapContainer extends React.Component { return []; } return await tooltipLayer.getPropertiesForTooltip(targetFeature.properties); - } + }; + + _findLayerById = (layerId) => { + return this.props.layerList.find(layer => { + return layer.getId() === layerId; + }); + }; _syncTooltipState() { if (this.props.tooltipState) {