[Maps] lazy load tooltip properties for Elasticsearch documents (#36059)

* lazy load tooltip properties for Elasticsearch documents

* return with loadProps exception

* fix errors

* fix feature_tooltip tests

* return empty array instead of throw when target layer or feature can not be found
This commit is contained in:
Nathan Reese 2019-05-20 15:56:52 -06:00 committed by GitHub
parent ed7c2dcdca
commit 08a4463fcf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 224 additions and 41 deletions

View file

@ -161,6 +161,19 @@ exports[`FeatureTooltip should show close button, but not filter button 1`] = `
</Fragment>
`;
exports[`FeatureTooltip should show error message if unable to load tooltip content 1`] = `
<EuiCallOut
color="danger"
iconType="cross"
size="m"
title="Unable to load tooltip content"
>
<p>
Simulated load properties error
</p>
</EuiCallOut>
`;
exports[`FeatureTooltip should show only filter button for filterable properties 1`] = `
<Fragment>
<EuiFlexGroup

View file

@ -5,12 +5,77 @@
*/
import React, { Fragment } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiCallOut, EuiLoadingSpinner } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export class FeatureTooltip extends React.Component {
state = {
properties: undefined,
loadPropertiesErrorMsg: undefined,
};
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.tooltipState.featureId,
nextLayerId: this.props.tooltipState.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
});
}
}
_renderFilterButton(tooltipProperty) {
if (!this.props.showFilterButtons || !tooltipProperty.isFilterable()) {
return null;
@ -38,7 +103,7 @@ export class FeatureTooltip extends React.Component {
}
_renderProperties(hasFilters) {
return this.props.properties.map((tooltipProperty, index) => {
return this.state.properties.map((tooltipProperty, index) => {
/*
* Justification for dangerouslySetInnerHTML:
* Property value contains value generated by Field formatter
@ -90,6 +155,30 @@ export class FeatureTooltip extends React.Component {
}
render() {
if (!this.state.properties) {
return (
<div>
<EuiLoadingSpinner size="m" /> {' loading content'}
</div>
);
}
if (this.state.loadPropertiesErrorMsg) {
return (
<EuiCallOut
title={i18n.translate('xpack.maps.tooltip.unableToLoadContentTitle', {
defaultMessage: 'Unable to load tooltip content'
})}
color="danger"
iconType="cross"
>
<p>
{this.state.loadPropertiesErrorMsg}
</p>
</EuiCallOut>
);
}
return (
<Fragment>
<EuiFlexGroup direction="column" gutterSize="none">

View file

@ -5,7 +5,7 @@
*/
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { shallow } from 'enzyme';
import { FeatureTooltip } from './feature_tooltip';
class MockTooltipProperty {
@ -33,7 +33,11 @@ class MockTooltipProperty {
}
const defaultProps = {
properties: [],
loadFeatureProperties: () => { return []; },
tooltipState: {
layerId: 'layer1',
featureId: 'feature1',
},
closeTooltip: () => {},
showFilterButtons: false,
showCloseButton: false
@ -45,54 +49,93 @@ const mockTooltipProperties = [
new MockTooltipProperty('foo', 'bar', false)
];
describe('FeatureTooltip', () => {
describe('FeatureTooltip', async () => {
test('should not show close button and not show filter button', () => {
const component = shallowWithIntl(
test('should not show close button and not show filter button', async () => {
const component = shallow(
<FeatureTooltip
{...defaultProps}
/>
);
// 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 close button, but not filter button', () => {
const component = shallowWithIntl(
test('should show close button, but not filter button', async () => {
const component = shallow(
<FeatureTooltip
{...defaultProps}
showCloseButton={true}
/>
);
// 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', () => {
const component = shallowWithIntl(
test('should show only filter button for filterable properties', async () => {
const component = shallow(
<FeatureTooltip
{...defaultProps}
showFilterButtons={true}
properties={mockTooltipProperties}
loadFeatureProperties={() => { 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 both filter buttons and close button', () => {
const component = shallowWithIntl(
test('should show both filter buttons and close button', async () => {
const component = shallow(
<FeatureTooltip
{...defaultProps}
showFilterButtons={true}
showCloseButton={true}
properties={mockTooltipProperties}
loadFeatureProperties={() => { 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(
<FeatureTooltip
{...defaultProps}
showFilterButtons={true}
showCloseButton={true}
loadFeatureProperties={() => { 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();
});

View file

@ -363,33 +363,38 @@ export class MBMapContainer extends React.Component {
}
}
_renderContentToTooltip(content, location) {
_showTooltip() {
if (!this._isMounted) {
return;
}
const isLocked = this.props.tooltipState.type === TOOLTIP_TYPE.LOCKED;
ReactDOM.render((
<FeatureTooltip
properties={content}
tooltipState={this.props.tooltipState}
loadFeatureProperties={this._loadFeatureProperties}
closeTooltip={this._onTooltipClose}
showFilterButtons={this.props.isFilterable && isLocked}
showCloseButton={isLocked}
/>
), this._tooltipContainer);
this._mbPopup.setLngLat(location)
this._mbPopup.setLngLat(this.props.tooltipState.location)
.setDOMContent(this._tooltipContainer)
.addTo(this._mbMap);
}
async _showTooltip() {
_loadFeatureProperties = async ({ layerId, featureId }) => {
const tooltipLayer = this.props.layerList.find(layer => {
return layer.getId() === this.props.tooltipState.layerId;
return layer.getId() === layerId;
});
const targetFeature = tooltipLayer.getFeatureById(this.props.tooltipState.featureId);
const formattedProperties = await tooltipLayer.getPropertiesForTooltip(targetFeature.properties);
this._renderContentToTooltip(formattedProperties, this.props.tooltipState.location);
if (!tooltipLayer) {
return [];
}
const targetFeature = tooltipLayer.getFeatureById(featureId);
if (!targetFeature) {
return [];
}
return await tooltipLayer.getPropertiesForTooltip(targetFeature.properties);
}
_syncTooltipState() {

View file

@ -10,6 +10,7 @@ import uuid from 'uuid/v4';
import { VECTOR_SHAPE_TYPES } from '../vector_feature_types';
import { AbstractESSource } from '../es_source';
import { SearchSource } from '../../../../kibana_services';
import { hitsToGeoJson } from '../../../../elasticsearch_geo_utils';
import { CreateSourceEditor } from './create_source_editor';
import { UpdateSourceEditor } from './update_source_editor';
@ -86,10 +87,7 @@ export class ESSearchSource extends AbstractESSource {
}
getFieldNames() {
return [
this._descriptor.geoField,
...this._descriptor.tooltipProperties
];
return [this._descriptor.geoField];
}
async getImmutableProperties() {
@ -141,10 +139,13 @@ export class ESSearchSource extends AbstractESSource {
let featureCollection;
const indexPattern = await this._getIndexPattern();
const unusedMetaFields = indexPattern.metaFields.filter(metaField => {
return metaField !== '_id';
});
const flattenHit = hit => {
const properties = indexPattern.flattenHit(hit);
// remove metaFields
indexPattern.metaFields.forEach(metaField => {
unusedMetaFields.forEach(metaField => {
delete properties[metaField];
});
return properties;
@ -155,7 +156,12 @@ export class ESSearchSource extends AbstractESSource {
const geoField = await this._getGeoField();
featureCollection = hitsToGeoJson(resp.hits.hits, flattenHit, geoField.name, geoField.type);
} catch(error) {
throw new Error(`Unable to convert search response to geoJson feature collection, error: ${error.message}`);
throw new Error(
i18n.translate('xpack.maps.source.esSearch.convertToGeoJsonErrorMsg', {
defaultMessage: 'Unable to convert search response to geoJson feature collection, error: {errorMsg}',
values: { errorMsg: error.message }
})
);
}
return {
@ -170,20 +176,47 @@ export class ESSearchSource extends AbstractESSource {
return this._descriptor.tooltipProperties.length > 0;
}
async filterAndFormatPropertiesToHtml(properties) {
const tooltipProps = [];
let indexPattern;
try {
indexPattern = await this._getIndexPattern();
} catch(error) {
console.warn(`Unable to find Index pattern ${this._descriptor.indexPatternId}, values are not formatted`);
return [];
async _loadTooltipProperties(docId, indexPattern) {
if (this._descriptor.tooltipProperties.length === 0) {
return {};
}
this._descriptor.tooltipProperties.forEach(propertyName => {
tooltipProps.push(new ESTooltipProperty(propertyName, properties[propertyName], indexPattern));
const searchSource = new SearchSource();
searchSource.setField('index', indexPattern);
searchSource.setField('size', 1);
const query = {
language: 'kuery',
query: `_id:${docId}`
};
searchSource.setField('query', query);
searchSource.setField('fields', this._descriptor.tooltipProperties);
const resp = await searchSource.fetch();
const hit = _.get(resp, 'hits.hits[0]');
if (!hit) {
throw new Error(
i18n.translate('xpack.maps.source.esSearch.loadTooltipPropertiesErrorMsg', {
defaultMessage: 'Unable to find document, _id: {docId}',
values: { docId }
})
);
}
const properties = indexPattern.flattenHit(hit);
indexPattern.metaFields.forEach(metaField => {
delete properties[metaField];
});
return properties;
}
async filterAndFormatPropertiesToHtml(properties) {
const indexPattern = await this._getIndexPattern();
const propertyValues = await this._loadTooltipProperties(properties._id, indexPattern);
return this._descriptor.tooltipProperties.map(propertyName => {
return new ESTooltipProperty(propertyName, propertyValues[propertyName], indexPattern);
});
return tooltipProps;
}
isFilterByMapBounds() {