[Visualize] Allows editing broken visualizations caused by runtime fields changes (#94798)

* Add possibility to open visualization when saved field doesn't exists anymore

* Fix tests

* Fix some remarks

* Remove unneeded code

* Fix tests

* Fix tests

* Fix some remarks

* Fixed problem with double error popover in visualizations

* Fix CI

* Fix type

* Fix API docs

* Don't show error popup for error related to runtime fields

* Fix some remarks

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Uladzislau Lasitsa 2021-04-08 17:29:57 +03:00 committed by GitHub
parent 5d54e2990b
commit e3f31ea9ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 268 additions and 87 deletions

View file

@ -27,6 +27,7 @@ export declare enum KBN_FIELD_TYPES
| HISTOGRAM | <code>&quot;histogram&quot;</code> | |
| IP | <code>&quot;ip&quot;</code> | |
| IP\_RANGE | <code>&quot;ip_range&quot;</code> | |
| MISSING | <code>&quot;missing&quot;</code> | |
| MURMUR3 | <code>&quot;murmur3&quot;</code> | |
| NESTED | <code>&quot;nested&quot;</code> | |
| NUMBER | <code>&quot;number&quot;</code> | |

View file

@ -27,6 +27,7 @@ export declare enum KBN_FIELD_TYPES
| HISTOGRAM | <code>&quot;histogram&quot;</code> | |
| IP | <code>&quot;ip&quot;</code> | |
| IP\_RANGE | <code>&quot;ip_range&quot;</code> | |
| MISSING | <code>&quot;missing&quot;</code> | |
| MURMUR3 | <code>&quot;murmur3&quot;</code> | |
| NESTED | <code>&quot;nested&quot;</code> | |
| NUMBER | <code>&quot;number&quot;</code> | |

View file

@ -9,9 +9,9 @@ Merges input$ and output$ streams and debounces emit till next macro-task. Could
<b>Signature:</b>
```typescript
getUpdated$(): Readonly<Rx.Observable<void>>;
getUpdated$(): Readonly<Rx.Observable<TEmbeddableInput | TEmbeddableOutput>>;
```
<b>Returns:</b>
`Readonly<Rx.Observable<void>>`
`Readonly<Rx.Observable<TEmbeddableInput | TEmbeddableOutput>>`

View file

@ -80,4 +80,5 @@ export enum KBN_FIELD_TYPES {
OBJECT = 'object',
NESTED = 'nested',
HISTOGRAM = 'histogram',
MISSING = 'missing',
}

View file

@ -230,7 +230,7 @@ describe('AggConfigs', () => {
describe('#toDsl', () => {
beforeEach(() => {
indexPattern = stubIndexPattern as IndexPattern;
indexPattern.fields.getByName = (name) => (name as unknown) as IndexPatternField;
indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField);
});
it('uses the sorted aggs', () => {

View file

@ -16,16 +16,33 @@ import { AggConfigs, CreateAggConfigParams } from '../agg_configs';
import { BUCKET_TYPES } from './bucket_agg_types';
import { IBucketAggConfig } from './bucket_agg_type';
import { mockAggTypesRegistry } from '../test_helpers';
import type { IndexPatternField } from '../../../index_patterns';
import { IndexPattern } from '../../../index_patterns/index_patterns/index_pattern';
const indexPattern = {
id: '1234',
title: 'logstash-*',
fields: [
{
name: 'field',
name: 'machine.os.raw',
type: 'string',
esTypes: ['string'],
aggregatable: true,
filterable: true,
searchable: true,
},
{
name: 'geo.src',
type: 'string',
esTypes: ['string'],
aggregatable: true,
filterable: true,
searchable: true,
},
],
} as any;
} as IndexPattern;
indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField);
const singleTerm = {
aggs: [

View file

@ -10,6 +10,8 @@ import { AggConfigs } from '../agg_configs';
import { METRIC_TYPES } from '../metrics';
import { mockAggTypesRegistry } from '../test_helpers';
import { BUCKET_TYPES } from './bucket_agg_types';
import type { IndexPatternField } from '../../../index_patterns';
import { IndexPattern } from '../../../index_patterns/index_patterns/index_pattern';
describe('Terms Agg', () => {
describe('order agg editor UI', () => {
@ -17,16 +19,44 @@ describe('Terms Agg', () => {
const indexPattern = {
id: '1234',
title: 'logstash-*',
fields: {
getByName: () => field,
filter: () => [field],
},
} as any;
fields: [
{
name: 'field',
type: 'string',
esTypes: ['string'],
aggregatable: true,
filterable: true,
searchable: true,
},
{
name: 'string_field',
type: 'string',
esTypes: ['string'],
aggregatable: true,
filterable: true,
searchable: true,
},
{
name: 'empty_number_field',
type: 'number',
esTypes: ['number'],
aggregatable: true,
filterable: true,
searchable: true,
},
{
name: 'number_field',
type: 'number',
esTypes: ['number'],
aggregatable: true,
filterable: true,
searchable: true,
},
],
} as IndexPattern;
const field = {
name: 'field',
indexPattern,
};
indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField);
indexPattern.fields.filter = () => indexPattern.fields;
return new AggConfigs(
indexPattern,
@ -207,16 +237,28 @@ describe('Terms Agg', () => {
const indexPattern = {
id: '1234',
title: 'logstash-*',
fields: {
getByName: () => field,
filter: () => [field],
},
} as any;
fields: [
{
name: 'string_field',
type: 'string',
esTypes: ['string'],
aggregatable: true,
filterable: true,
searchable: true,
},
{
name: 'number_field',
type: 'number',
esTypes: ['number'],
aggregatable: true,
filterable: true,
searchable: true,
},
],
} as IndexPattern;
const field = {
name: 'field',
indexPattern,
};
indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField);
indexPattern.fields.filter = () => indexPattern.fields;
const aggConfigs = new AggConfigs(
indexPattern,

View file

@ -8,7 +8,10 @@
import { i18n } from '@kbn/i18n';
import { IAggConfig } from '../agg_config';
import { SavedObjectNotFound } from '../../../../../../plugins/kibana_utils/common';
import {
SavedFieldNotFound,
SavedFieldTypeInvalidForAgg,
} from '../../../../../../plugins/kibana_utils/common';
import { BaseParamType } from './base';
import { propFilter } from '../utils';
import { KBN_FIELD_TYPES } from '../../../kbn_field_types/types';
@ -47,13 +50,49 @@ export class FieldParamType extends BaseParamType {
);
}
if (field.scripted) {
if (field.type === KBN_FIELD_TYPES.MISSING) {
throw new SavedFieldNotFound(
i18n.translate(
'data.search.aggs.paramTypes.field.notFoundSavedFieldParameterErrorMessage',
{
defaultMessage:
'The field "{fieldParameter}" associated with this object no longer exists in the index pattern. Please use another field.',
values: {
fieldParameter: field.name,
},
}
)
);
}
const validField = this.getAvailableFields(aggConfig).find(
(f: any) => f.name === field.name
);
if (!validField) {
throw new SavedFieldTypeInvalidForAgg(
i18n.translate(
'data.search.aggs.paramTypes.field.invalidSavedFieldParameterErrorMessage',
{
defaultMessage:
'Saved field "{fieldParameter}" of index pattern "{indexPatternTitle}" is invalid for use with the "{aggType}" aggregation. Please select a new field.',
values: {
fieldParameter: field.name,
aggType: aggConfig?.type?.title,
indexPatternTitle: aggConfig.getIndexPattern().title,
},
}
)
);
}
if (validField.scripted) {
output.params.script = {
source: field.script,
lang: field.lang,
source: validField.script,
lang: validField.lang,
};
} else {
output.params.field = field.name;
output.params.field = validField.name;
}
};
}
@ -69,28 +108,15 @@ export class FieldParamType extends BaseParamType {
const field = aggConfig.getIndexPattern().fields.getByName(fieldName);
if (!field) {
throw new SavedObjectNotFound('index-pattern-field', fieldName);
return new IndexPatternField({
type: KBN_FIELD_TYPES.MISSING,
name: fieldName,
searchable: false,
aggregatable: false,
});
}
const validField = this.getAvailableFields(aggConfig).find((f: any) => f.name === fieldName);
if (!validField) {
throw new Error(
i18n.translate(
'data.search.aggs.paramTypes.field.invalidSavedFieldParameterErrorMessage',
{
defaultMessage:
'Saved field "{fieldParameter}" of index pattern "{indexPatternTitle}" is invalid for use with the "{aggType}" aggregation. Please select a new field.',
values: {
fieldParameter: fieldName,
aggType: aggConfig?.type?.title,
indexPatternTitle: aggConfig.getIndexPattern().title,
},
}
)
);
}
return validField;
return field;
};
}

View file

@ -1786,6 +1786,8 @@ export enum KBN_FIELD_TYPES {
// (undocumented)
IP_RANGE = "ip_range",
// (undocumented)
MISSING = "missing",
// (undocumented)
MURMUR3 = "murmur3",
// (undocumented)
NESTED = "nested",

View file

@ -1082,6 +1082,8 @@ export enum KBN_FIELD_TYPES {
// (undocumented)
IP_RANGE = "ip_range",
// (undocumented)
MISSING = "missing",
// (undocumented)
MURMUR3 = "murmur3",
// (undocumented)
NESTED = "nested",

View file

@ -9,7 +9,7 @@
import { cloneDeep, isEqual } from 'lodash';
import * as Rx from 'rxjs';
import { merge } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, mapTo, skip } from 'rxjs/operators';
import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators';
import { RenderCompleteDispatcher } from '../../../../kibana_utils/public';
import { Adapters } from '../types';
import { IContainer } from '../containers';
@ -111,10 +111,9 @@ export abstract class Embeddable<
* In case corresponding state change triggered `reload` this stream is guarantied to emit later,
* which allows to skip any state handling in case `reload` already handled it.
*/
public getUpdated$(): Readonly<Rx.Observable<void>> {
public getUpdated$(): Readonly<Rx.Observable<TEmbeddableInput | TEmbeddableOutput>> {
return merge(this.getInput$().pipe(skip(1)), this.getOutput$().pipe(skip(1))).pipe(
debounceTime(0),
mapTo(undefined)
debounceTime(0)
);
}

View file

@ -282,7 +282,7 @@ export abstract class Embeddable<TEmbeddableInput extends EmbeddableInput = Embe
getRoot(): IEmbeddable | IContainer;
// (undocumented)
getTitle(): string;
getUpdated$(): Readonly<Rx.Observable<void>>;
getUpdated$(): Readonly<Rx.Observable<TEmbeddableInput | TEmbeddableOutput>>;
// (undocumented)
readonly id: string;
// (undocumented)

View file

@ -32,7 +32,7 @@ export class DuplicateField extends KbnError {
export class SavedObjectNotFound extends KbnError {
public savedObjectType: string;
public savedObjectId?: string;
constructor(type: string, id?: string, link?: string) {
constructor(type: string, id?: string, link?: string, customMessage?: string) {
const idMsg = id ? ` (id: ${id})` : '';
let message = `Could not locate that ${type}${idMsg}`;
@ -40,13 +40,31 @@ export class SavedObjectNotFound extends KbnError {
message += `, [click here to re-create it](${link})`;
}
super(message);
super(customMessage || message);
this.savedObjectType = type;
this.savedObjectId = id;
}
}
/**
* A saved field doesn't exist anymore
*/
export class SavedFieldNotFound extends KbnError {
constructor(message: string) {
super(message);
}
}
/**
* A saved field type isn't compatible with aggregation
*/
export class SavedFieldTypeInvalidForAgg extends KbnError {
constructor(message: string) {
super(message);
}
}
/**
* This error is for scenarios where a saved object is detected that has invalid JSON properties.
* There was a scenario where we were importing objects with double-encoded JSON, and the system

View file

@ -11,7 +11,7 @@ import { act } from 'react-dom/test-utils';
import { mount, shallow, ReactWrapper } from 'enzyme';
import { EuiComboBoxProps, EuiComboBox } from '@elastic/eui';
import { IAggConfig, IndexPatternField } from 'src/plugins/data/public';
import { IAggConfig, IndexPatternField, AggParam } from 'src/plugins/data/public';
import { ComboBoxGroupedOptions } from '../../utils';
import { FieldParamEditor, FieldParamEditorProps } from './field';
import { EditorVisState } from '../sidebar/state/reducers';
@ -42,7 +42,7 @@ describe('FieldParamEditor component', () => {
setTouched = jest.fn();
onChange = jest.fn();
field = { displayName: 'bytes' } as IndexPatternField;
field = { displayName: 'bytes', type: 'bytes' } as IndexPatternField;
option = { label: 'bytes', target: field };
indexedFields = [
{
@ -52,7 +52,16 @@ describe('FieldParamEditor component', () => {
];
defaultProps = {
agg: {} as IAggConfig,
agg: {
type: {
params: [
({
name: 'field',
filterFieldTypes: ['bytes'],
} as unknown) as AggParam,
],
},
} as IAggConfig,
aggParam: {
name: 'field',
type: 'field',

View file

@ -13,7 +13,13 @@ import useMount from 'react-use/lib/useMount';
import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AggParam, IAggConfig, IFieldParamType, IndexPatternField } from 'src/plugins/data/public';
import {
AggParam,
IAggConfig,
IFieldParamType,
IndexPatternField,
KBN_FIELD_TYPES,
} from '../../../../../plugins/data/public';
import { formatListAsProse, parseCommaSeparatedList, useValidation } from './utils';
import { AggParamEditorProps } from '../agg_param_props';
import { ComboBoxGroupedOptions } from '../../utils';
@ -55,6 +61,7 @@ function FieldParamEditor({
}
};
const errors = customError ? [customError] : [];
let showErrorMessageImmediately = false;
if (!indexedFields.length) {
errors.push(
@ -69,9 +76,38 @@ function FieldParamEditor({
);
}
if (value && value.type === KBN_FIELD_TYPES.MISSING) {
errors.push(
i18n.translate('visDefaultEditor.controls.field.fieldIsNotExists', {
defaultMessage:
'The field "{fieldParameter}" associated with this object no longer exists in the index pattern. Please use another field.',
values: {
fieldParameter: value.name,
},
})
);
showErrorMessageImmediately = true;
} else if (
value &&
!getFieldTypes(agg).find((type: string) => type === value.type || type === '*')
) {
errors.push(
i18n.translate('visDefaultEditor.controls.field.invalidFieldForAggregation', {
defaultMessage:
'Saved field "{fieldParameter}" of index pattern "{indexPatternTitle}" is invalid for use with this aggregation. Please select a new field.',
values: {
fieldParameter: value?.name,
indexPatternTitle: agg.getIndexPattern && agg.getIndexPattern().title,
},
})
);
showErrorMessageImmediately = true;
}
const isValid = !!value && !errors.length && !isDirty;
// we show an error message right away if there is no compatible fields
const showErrorMessage = (showValidation || !indexedFields.length) && !isValid;
const showErrorMessage =
(showValidation || !indexedFields.length || showErrorMessageImmediately) && !isValid;
useValidation(setValidity, isValid);
useMount(() => {
@ -122,10 +158,14 @@ function FieldParamEditor({
}
function getFieldTypesString(agg: IAggConfig) {
return formatListAsProse(getFieldTypes(agg), { inclusive: false });
}
function getFieldTypes(agg: IAggConfig) {
const param =
get(agg, 'type.params', []).find((p: AggParam) => p.name === 'field') ||
({} as IFieldParamType);
return formatListAsProse(parseCommaSeparatedList(param.filterFieldTypes), { inclusive: false });
return parseCommaSeparatedList(param.filterFieldTypes || []);
}
export { FieldParamEditor };

View file

@ -149,8 +149,9 @@ export class VisualizeEmbeddable
}
this.subscriptions.push(
this.getUpdated$().subscribe(() => {
this.getUpdated$().subscribe((value) => {
const isDirty = this.handleChanges();
if (isDirty && this.handler) {
this.updateHandler();
}

View file

@ -18,8 +18,17 @@ import { SavedObject } from 'src/plugins/saved_objects/public';
import { cloneDeep } from 'lodash';
import { ExpressionValueError } from 'src/plugins/expressions/public';
import { createSavedSearchesLoader } from '../../../../discover/public';
import { SavedFieldNotFound, SavedFieldTypeInvalidForAgg } from '../../../../kibana_utils/common';
import { VisualizeServices } from '../types';
function isErrorRelatedToRuntimeFields(error: ExpressionValueError['error']) {
const originalError = error.original || error;
return (
originalError instanceof SavedFieldNotFound ||
originalError instanceof SavedFieldTypeInvalidForAgg
);
}
const createVisualizeEmbeddableAndLinkSavedSearch = async (
vis: Vis,
visualizeServices: VisualizeServices
@ -37,7 +46,7 @@ const createVisualizeEmbeddableAndLinkSavedSearch = async (
})) as VisualizeEmbeddableContract;
embeddableHandler.getOutput$().subscribe((output) => {
if (output.error) {
if (output.error && !isErrorRelatedToRuntimeFields(output.error)) {
data.search.showError(
((output.error as unknown) as ExpressionValueError['error']).original || output.error
);

View file

@ -11,13 +11,12 @@ import { EventEmitter } from 'events';
import { parse } from 'query-string';
import { i18n } from '@kbn/i18n';
import { redirectWhenMissing } from '../../../../../kibana_utils/public';
import { getVisualizationInstance } from '../get_visualization_instance';
import { getEditBreadcrumbs, getCreateBreadcrumbs } from '../breadcrumbs';
import { SavedVisInstance, VisualizeServices, IEditorController } from '../../types';
import { VisualizeConstants } from '../../visualize_constants';
import { getVisEditorsRegistry } from '../../../services';
import { redirectToSavedObjectPage } from '../utils';
/**
* This effect is responsible for instantiating a saved vis or creating a new one
@ -43,9 +42,7 @@ export const useSavedVisInstance = (
chrome,
history,
dashboard,
setActiveUrl,
toastNotifications,
http: { basePath },
stateTransferService,
application: { navigateToApp },
} = services;
@ -131,27 +128,8 @@ export const useSavedVisInstance = (
visEditorController,
});
} catch (error) {
const managementRedirectTarget = {
app: 'management',
path: `kibana/objects/savedVisualizations/${visualizationIdFromUrl}`,
};
try {
redirectWhenMissing({
history,
navigateToApp,
toastNotifications,
basePath,
mapping: {
visualization: VisualizeConstants.LANDING_PAGE_PATH,
search: managementRedirectTarget,
'index-pattern': managementRedirectTarget,
'index-pattern-field': managementRedirectTarget,
},
onBeforeRedirect() {
setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH);
},
})(error);
redirectToSavedObjectPage(services, error, visualizationIdFromUrl);
} catch (e) {
toastNotifications.addWarning({
title: i18n.translate('visualize.createVisualization.failedToLoadErrorMessage', {

View file

@ -10,6 +10,8 @@ import { i18n } from '@kbn/i18n';
import { ChromeStart, DocLinksStart } from 'kibana/public';
import { Filter } from '../../../../data/public';
import { redirectWhenMissing } from '../../../../kibana_utils/public';
import { VisualizeConstants } from '../visualize_constants';
import { VisualizeServices, VisualizeEditorVisInstance } from '../types';
export const addHelpMenuToAppChrome = (chrome: ChromeStart, docLinks: DocLinksStart) => {
@ -58,3 +60,36 @@ export const visStateToEditorState = (
linked: savedVis && savedVis.id ? !!savedVis.savedSearchId : !!savedVisState.savedSearchId,
};
};
export const redirectToSavedObjectPage = (
services: VisualizeServices,
error: any,
savedVisualizationsId?: string
) => {
const {
history,
setActiveUrl,
toastNotifications,
http: { basePath },
application: { navigateToApp },
} = services;
const managementRedirectTarget = {
app: 'management',
path: `kibana/objects/savedVisualizations/${savedVisualizationsId}`,
};
redirectWhenMissing({
history,
navigateToApp,
toastNotifications,
basePath,
mapping: {
visualization: VisualizeConstants.LANDING_PAGE_PATH,
search: managementRedirectTarget,
'index-pattern': managementRedirectTarget,
'index-pattern-field': managementRedirectTarget,
},
onBeforeRedirect() {
setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH);
},
})(error);
};