mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
ed7c2dcdca
commit
08a4463fcf
5 changed files with 224 additions and 41 deletions
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue