[Maps] Show joins disabled message (#70826)

Show feedback in the layer-settings when the scaling-method does not support Term-joins.
This commit is contained in:
Thomas Neirynck 2020-07-13 11:55:36 -04:00 committed by GitHub
parent 2c19feb55f
commit c44f019790
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 383 additions and 169 deletions

View file

@ -5,7 +5,7 @@
*/
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import { RENDER_AS, SORT_ORDER, SCALING_TYPES } from '../constants';
import { RENDER_AS, SORT_ORDER, SCALING_TYPES, SOURCE_TYPES } from '../constants';
import { MapExtent, MapQuery } from './map_descriptor';
import { Filter, TimeRange } from '../../../../../src/plugins/data/common';
@ -26,10 +26,12 @@ type ESSearchSourceSyncMeta = {
scalingType: SCALING_TYPES;
topHitsSplitField: string;
topHitsSize: number;
sourceType: SOURCE_TYPES.ES_SEARCH;
};
type ESGeoGridSourceSyncMeta = {
requestType: RENDER_AS;
sourceType: SOURCE_TYPES.ES_GEO_GRID;
};
export type VectorSourceSyncMeta = ESSearchSourceSyncMeta | ESGeoGridSourceSyncMeta | null;
@ -51,7 +53,6 @@ export type VectorStyleRequestMeta = MapFilters & {
export type ESSearchSourceResponseMeta = {
areResultsTrimmed?: boolean;
sourceType?: string;
// top hits meta
areEntitiesTrimmed?: boolean;

View file

@ -77,8 +77,8 @@ export type ESPewPewSourceDescriptor = AbstractESAggSourceDescriptor & {
};
export type ESTermSourceDescriptor = AbstractESAggSourceDescriptor & {
indexPatternTitle: string;
term: string; // term field name
indexPatternTitle?: string;
term?: string; // term field name
whereQuery?: Query;
};
@ -138,7 +138,7 @@ export type GeojsonFileSourceDescriptor = {
};
export type JoinDescriptor = {
leftField: string;
leftField?: string;
right: ESTermSourceDescriptor;
};

View file

@ -126,7 +126,7 @@ function getClusterStyleDescriptor(
),
}
: undefined;
// @ts-ignore
// @ts-expect-error
clusterStyleDescriptor.properties[styleName] = {
type: STYLE_TYPE.DYNAMIC,
options: {
@ -136,7 +136,7 @@ function getClusterStyleDescriptor(
};
} else {
// copy static styles to cluster style
// @ts-ignore
// @ts-expect-error
clusterStyleDescriptor.properties[styleName] = {
type: STYLE_TYPE.STATIC,
options: { ...styleProperty.getOptions() },
@ -192,8 +192,8 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer {
const requestMeta = sourceDataRequest.getMeta();
if (
requestMeta &&
requestMeta.sourceType &&
requestMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID
requestMeta.sourceMeta &&
requestMeta.sourceMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID
) {
isClustered = true;
}
@ -220,8 +220,12 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer {
: displayName;
}
isJoinable() {
return false;
showJoinEditor() {
return true;
}
getJoinsDisabledReason() {
return this._documentSource.getJoinsDisabledReason();
}
getJoins() {

View file

@ -78,6 +78,8 @@ export interface ILayer {
isPreviewLayer: () => boolean;
areLabelsOnTop: () => boolean;
supportsLabelsOnTop: () => boolean;
showJoinEditor(): boolean;
getJoinsDisabledReason(): string | null;
}
export type Footnote = {
icon: ReactElement<any>;
@ -141,13 +143,12 @@ export class AbstractLayer implements ILayer {
}
static getBoundDataForSource(mbMap: unknown, sourceId: string): FeatureCollection {
// @ts-ignore
// @ts-expect-error
const mbStyle = mbMap.getStyle();
return mbStyle.sources[sourceId].data;
}
async cloneDescriptor(): Promise<LayerDescriptor> {
// @ts-ignore
const clonedDescriptor = copyPersistentState(this._descriptor);
// layer id is uuid used to track styles/layers in mapbox
clonedDescriptor.id = uuid();
@ -155,14 +156,10 @@ export class AbstractLayer implements ILayer {
clonedDescriptor.label = `Clone of ${displayName}`;
clonedDescriptor.sourceDescriptor = this.getSource().cloneDescriptor();
// todo: remove this
// This should not be in AbstractLayer. It relies on knowledge of VectorLayerDescriptor
// @ts-ignore
if (clonedDescriptor.joins) {
// @ts-ignore
// @ts-expect-error
clonedDescriptor.joins.forEach((joinDescriptor) => {
// right.id is uuid used to track requests in inspector
// @ts-ignore
joinDescriptor.right.id = uuid();
});
}
@ -173,8 +170,12 @@ export class AbstractLayer implements ILayer {
return `${this.getId()}${MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER}${layerNameSuffix}`;
}
isJoinable(): boolean {
return this.getSource().isJoinable();
showJoinEditor(): boolean {
return this.getSource().showJoinEditor();
}
getJoinsDisabledReason() {
return this.getSource().getJoinsDisabledReason();
}
isPreviewLayer(): boolean {
@ -394,7 +395,6 @@ export class AbstractLayer implements ILayer {
const requestTokens = this._dataRequests.map((dataRequest) => dataRequest.getRequestToken());
// Compact removes all the undefineds
// @ts-ignore
return _.compact(requestTokens);
}
@ -478,7 +478,7 @@ export class AbstractLayer implements ILayer {
}
syncVisibilityWithMb(mbMap: unknown, mbLayerId: string) {
// @ts-ignore
// @ts-expect-error
mbMap.setLayoutProperty(mbLayerId, 'visibility', this.isVisible() ? 'visible' : 'none');
}

View file

@ -63,6 +63,7 @@ export class ESGeoGridSource extends AbstractESAggSource {
getSyncMeta() {
return {
requestType: this._descriptor.requestType,
sourceType: SOURCE_TYPES.ES_GEO_GRID,
};
}
@ -103,7 +104,7 @@ export class ESGeoGridSource extends AbstractESAggSource {
return true;
}
isJoinable() {
showJoinEditor() {
return false;
}
@ -307,7 +308,6 @@ export class ESGeoGridSource extends AbstractESAggSource {
},
meta: {
areResultsTrimmed: false,
sourceType: SOURCE_TYPES.ES_GEO_GRID,
},
};
}

View file

@ -51,7 +51,7 @@ export class ESPewPewSource extends AbstractESAggSource {
return true;
}
isJoinable() {
showJoinEditor() {
return false;
}

View file

@ -385,7 +385,7 @@ export class ESSearchSource extends AbstractESSource {
return {
data: featureCollection,
meta: { ...meta, sourceType: SOURCE_TYPES.ES_SEARCH },
meta,
};
}
@ -540,6 +540,7 @@ export class ESSearchSource extends AbstractESSource {
scalingType: this._descriptor.scalingType,
topHitsSplitField: this._descriptor.topHitsSplitField,
topHitsSize: this._descriptor.topHitsSize,
sourceType: SOURCE_TYPES.ES_SEARCH,
};
}
@ -551,6 +552,14 @@ export class ESSearchSource extends AbstractESSource {
path: geoField.name,
};
}
getJoinsDisabledReason() {
return this._descriptor.scalingType === SCALING_TYPES.CLUSTERS
? i18n.translate('xpack.maps.source.esSearch.joinsDisabledReason', {
defaultMessage: 'Joins are not supported when scaling by clusters',
})
: null;
}
}
registerSource({

View file

@ -54,7 +54,8 @@ export interface ISource {
isESSource(): boolean;
renderSourceSettingsEditor({ onChange }: SourceEditorArgs): ReactElement<any> | null;
supportsFitToBounds(): Promise<boolean>;
isJoinable(): boolean;
showJoinEditor(): boolean;
getJoinsDisabledReason(): string | null;
cloneDescriptor(): SourceDescriptor;
getFieldNames(): string[];
getApplyGlobalQuery(): boolean;
@ -80,7 +81,6 @@ export class AbstractSource implements ISource {
destroy(): void {}
cloneDescriptor(): SourceDescriptor {
// @ts-ignore
return copyPersistentState(this._descriptor);
}
@ -148,10 +148,14 @@ export class AbstractSource implements ISource {
return 0;
}
isJoinable(): boolean {
showJoinEditor(): boolean {
return false;
}
getJoinsDisabledReason() {
return null;
}
isESSource(): boolean {
return false;
}

View file

@ -122,7 +122,7 @@ export class AbstractVectorSource extends AbstractSource {
return false;
}
isJoinable() {
showJoinEditor() {
return true;
}

View file

@ -96,8 +96,8 @@ exports[`LayerPanel is rendered 1`] = `
"getId": [Function],
"getImmutableSourceProperties": [Function],
"getLayerTypeIconName": [Function],
"isJoinable": [Function],
"renderSourceSettingsEditor": [Function],
"showJoinEditor": [Function],
"supportsElasticsearchFilters": [Function],
}
}
@ -107,6 +107,17 @@ exports[`LayerPanel is rendered 1`] = `
</div>
<EuiPanel>
<JoinEditor
layer={
Object {
"getDisplayName": [Function],
"getId": [Function],
"getImmutableSourceProperties": [Function],
"getLayerTypeIconName": [Function],
"renderSourceSettingsEditor": [Function],
"showJoinEditor": [Function],
"supportsElasticsearchFilters": [Function],
}
}
layerDisplayName="layer 1"
leftJoinFields={Array []}
/>

View file

@ -12,7 +12,7 @@ import { updateSourceProp } from '../../actions';
function mapStateToProps(state = {}) {
const selectedLayer = getSelectedLayer(state);
return {
key: selectedLayer ? `${selectedLayer.getId()}${selectedLayer.isJoinable()}` : '',
key: selectedLayer ? `${selectedLayer.getId()}${selectedLayer.showJoinEditor()}` : '',
selectedLayer,
};
}

View file

@ -0,0 +1,100 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Should render callout when joins are disabled 1`] = `
<div>
<EuiTitle
size="xs"
>
<h5>
<EuiToolTip
content="Use term joins to augment this layer with properties for data driven styling."
delay="regular"
position="top"
>
<FormattedMessage
defaultMessage="Term joins"
id="xpack.maps.layerPanel.joinEditor.termJoinsTitle"
values={Object {}}
/>
</EuiToolTip>
</h5>
</EuiTitle>
<EuiCallOut
color="warning"
>
Simulated disabled reason
</EuiCallOut>
</div>
`;
exports[`Should render join editor 1`] = `
<div>
<EuiTitle
size="xs"
>
<h5>
<EuiToolTip
content="Use term joins to augment this layer with properties for data driven styling."
delay="regular"
position="top"
>
<FormattedMessage
defaultMessage="Term joins"
id="xpack.maps.layerPanel.joinEditor.termJoinsTitle"
values={Object {}}
/>
</EuiToolTip>
</h5>
</EuiTitle>
<EuiSpacer
size="m"
/>
<Join
join={
Object {
"leftField": "iso2",
"right": Object {
"id": "673ff994-fc75-4c67-909b-69fcb0e1060e",
"indexPatternId": "abcde",
"indexPatternTitle": "kibana_sample_data_logs",
"metrics": Array [
Object {
"label": "web logs count",
"type": "count",
},
],
"term": "geo.src",
},
}
}
layer={
MockLayer {
"_disableReason": null,
}
}
leftFields={Array []}
leftSourceName="myLeftJoinField"
onChange={[Function]}
onRemove={[Function]}
/>
<EuiSpacer
size="s"
/>
<EuiTextAlign
textAlign="center"
>
<EuiButtonEmpty
aria-label="Add join"
iconType="plusInCircle"
onClick={[Function]}
size="xs"
>
<FormattedMessage
defaultMessage="Add join"
id="xpack.maps.layerPanel.joinEditor.addJoinButtonLabel"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiTextAlign>
</div>
`;

View file

@ -1,31 +0,0 @@
/*
* 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 { connect } from 'react-redux';
import { JoinEditor } from './view';
import {
getSelectedLayer,
getSelectedLayerJoinDescriptors,
} from '../../../selectors/map_selectors';
import { setJoinsForLayer } from '../../../actions';
function mapDispatchToProps(dispatch) {
return {
onChange: (layer, joins) => {
dispatch(setJoinsForLayer(layer, joins));
},
};
}
function mapStateToProps(state = {}) {
return {
joins: getSelectedLayerJoinDescriptors(state),
layer: getSelectedLayer(state),
};
}
const connectedJoinEditor = connect(mapStateToProps, mapDispatchToProps)(JoinEditor);
export { connectedJoinEditor as JoinEditor };

View file

@ -0,0 +1,31 @@
/*
* 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 { AnyAction, Dispatch } from 'redux';
import { connect } from 'react-redux';
import { JoinEditor } from './join_editor';
import { getSelectedLayerJoinDescriptors } from '../../../selectors/map_selectors';
import { setJoinsForLayer } from '../../../actions';
import { MapStoreState } from '../../../reducers/store';
import { ILayer } from '../../../classes/layers/layer';
import { JoinDescriptor } from '../../../../common/descriptor_types';
function mapStateToProps(state: MapStoreState) {
return {
joins: getSelectedLayerJoinDescriptors(state),
};
}
function mapDispatchToProps(dispatch: Dispatch<AnyAction>) {
return {
onChange: (layer: ILayer, joins: JoinDescriptor[]) => {
dispatch<any>(setJoinsForLayer(layer, joins));
},
};
}
const connectedJoinEditor = connect(mapStateToProps, mapDispatchToProps)(JoinEditor);
export { connectedJoinEditor as JoinEditor };

View file

@ -0,0 +1,63 @@
/*
* 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 { ILayer } from '../../../classes/layers/layer';
import { JoinEditor } from './join_editor';
import { shallow } from 'enzyme';
import { JoinDescriptor } from '../../../../common/descriptor_types';
class MockLayer {
private readonly _disableReason: string | null;
constructor(disableReason: string | null) {
this._disableReason = disableReason;
}
getJoinsDisabledReason() {
return this._disableReason;
}
}
const defaultProps = {
joins: [
{
leftField: 'iso2',
right: {
id: '673ff994-fc75-4c67-909b-69fcb0e1060e',
indexPatternTitle: 'kibana_sample_data_logs',
term: 'geo.src',
indexPatternId: 'abcde',
metrics: [
{
type: 'count',
label: 'web logs count',
},
],
},
} as JoinDescriptor,
],
layerDisplayName: 'myLeftJoinField',
leftJoinFields: [],
onChange: () => {},
};
test('Should render join editor', () => {
const component = shallow(
<JoinEditor {...defaultProps} layer={(new MockLayer(null) as unknown) as ILayer} />
);
expect(component).toMatchSnapshot();
});
test('Should render callout when joins are disabled', () => {
const component = shallow(
<JoinEditor
{...defaultProps}
layer={(new MockLayer('Simulated disabled reason') as unknown) as ILayer}
/>
);
expect(component).toMatchSnapshot();
});

View file

@ -0,0 +1,124 @@
/*
* 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, { Fragment } from 'react';
import uuid from 'uuid/v4';
import {
EuiButtonEmpty,
EuiTitle,
EuiSpacer,
EuiToolTip,
EuiTextAlign,
EuiCallOut,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
// @ts-expect-error
import { Join } from './resources/join';
import { ILayer } from '../../../classes/layers/layer';
import { JoinDescriptor } from '../../../../common/descriptor_types';
import { IField } from '../../../classes/fields/field';
interface Props {
joins: JoinDescriptor[];
layer: ILayer;
layerDisplayName: string;
leftJoinFields: IField[];
onChange: (layer: ILayer, joins: JoinDescriptor[]) => void;
}
export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDisplayName }: Props) {
const renderJoins = () => {
return joins.map((joinDescriptor: JoinDescriptor, index: number) => {
const handleOnChange = (updatedDescriptor: JoinDescriptor) => {
onChange(layer, [...joins.slice(0, index), updatedDescriptor, ...joins.slice(index + 1)]);
};
const handleOnRemove = () => {
onChange(layer, [...joins.slice(0, index), ...joins.slice(index + 1)]);
};
return (
<Fragment key={index}>
<EuiSpacer size="m" />
<Join
join={joinDescriptor}
layer={layer}
onChange={handleOnChange}
onRemove={handleOnRemove}
leftFields={leftJoinFields}
leftSourceName={layerDisplayName}
/>
</Fragment>
);
});
};
const addJoin = () => {
onChange(layer, [
...joins,
{
right: {
id: uuid(),
applyGlobalQuery: true,
},
} as JoinDescriptor,
]);
};
const renderContent = () => {
const disabledReason = layer.getJoinsDisabledReason();
return disabledReason ? (
<EuiCallOut color="warning">{disabledReason}</EuiCallOut>
) : (
<Fragment>
{renderJoins()}
<EuiSpacer size="s" />
<EuiTextAlign textAlign="center">
<EuiButtonEmpty
onClick={addJoin}
size="xs"
iconType="plusInCircle"
aria-label={i18n.translate('xpack.maps.layerPanel.joinEditor.addJoinAriaLabel', {
defaultMessage: 'Add join',
})}
>
<FormattedMessage
id="xpack.maps.layerPanel.joinEditor.addJoinButtonLabel"
defaultMessage="Add join"
/>
</EuiButtonEmpty>
</EuiTextAlign>
</Fragment>
);
};
return (
<div>
<EuiTitle size="xs">
<h5>
<EuiToolTip
content={i18n.translate('xpack.maps.layerPanel.joinEditor.termJoinTooltip', {
defaultMessage:
'Use term joins to augment this layer with properties for data driven styling.',
})}
>
<FormattedMessage
id="xpack.maps.layerPanel.joinEditor.termJoinsTitle"
defaultMessage="Term joins"
/>
</EuiToolTip>
</h5>
</EuiTitle>
{renderContent()}
</div>
);
}

View file

@ -1,103 +0,0 @@
/*
* 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, { Fragment } from 'react';
import uuid from 'uuid/v4';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiTitle,
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import { Join } from './resources/join';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDisplayName }) {
const renderJoins = () => {
return joins.map((joinDescriptor, index) => {
const handleOnChange = (updatedDescriptor) => {
onChange(layer, [...joins.slice(0, index), updatedDescriptor, ...joins.slice(index + 1)]);
};
const handleOnRemove = () => {
onChange(layer, [...joins.slice(0, index), ...joins.slice(index + 1)]);
};
return (
<Fragment key={index}>
<EuiSpacer size="m" />
<Join
join={joinDescriptor}
layer={layer}
onChange={handleOnChange}
onRemove={handleOnRemove}
leftFields={leftJoinFields}
leftSourceName={layerDisplayName}
/>
</Fragment>
);
});
};
const addJoin = () => {
onChange(layer, [
...joins,
{
right: {
id: uuid(),
applyGlobalQuery: true,
},
},
]);
};
if (!layer.isJoinable()) {
return null;
}
return (
<div>
<EuiFlexGroup responsive={false}>
<EuiFlexItem>
<EuiTitle size="xs">
<h5>
<EuiToolTip
content={i18n.translate('xpack.maps.layerPanel.joinEditor.termJoinTooltip', {
defaultMessage:
'Use term joins to augment this layer with properties for data driven styling.',
})}
>
<FormattedMessage
id="xpack.maps.layerPanel.joinEditor.termJoinsTitle"
defaultMessage="Term joins"
/>
</EuiToolTip>
</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="plusInCircle"
onClick={addJoin}
aria-label={i18n.translate('xpack.maps.layerPanel.joinEditor.addJoinAriaLabel', {
defaultMessage: 'Add join',
})}
title={i18n.translate('xpack.maps.layerPanel.joinEditor.addJoinButtonLabel', {
defaultMessage: 'Add join',
})}
/>
</EuiFlexItem>
</EuiFlexGroup>
{renderJoins()}
</div>
);
}

View file

@ -75,7 +75,7 @@ export class LayerPanel extends React.Component {
};
async _loadLeftJoinFields() {
if (!this.props.selectedLayer || !this.props.selectedLayer.isJoinable()) {
if (!this.props.selectedLayer || !this.props.selectedLayer.showJoinEditor()) {
return;
}
@ -120,7 +120,7 @@ export class LayerPanel extends React.Component {
}
_renderJoinSection() {
if (!this.props.selectedLayer.isJoinable()) {
if (!this.props.selectedLayer.showJoinEditor()) {
return null;
}
@ -128,6 +128,7 @@ export class LayerPanel extends React.Component {
<Fragment>
<EuiPanel>
<JoinEditor
layer={this.props.selectedLayer}
leftJoinFields={this.state.leftJoinFields}
layerDisplayName={this.state.displayName}
/>

View file

@ -55,7 +55,7 @@ const mockLayer = {
getImmutableSourceProperties: () => {
return [{ label: 'source prop1', value: 'you get one chance to set me' }];
},
isJoinable: () => {
showJoinEditor: () => {
return true;
},
supportsElasticsearchFilters: () => {