mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[8.9] [controls] fix Dashboard getting stuck at loading in Kibana when Controls is used and mapping changed from integer to keyword (#163529) (#163750)
# Backport This will backport the following commits from `main` to `8.9`: - [[controls] fix Dashboard getting stuck at loading in Kibana when Controls is used and mapping changed from integer to keyword (#163529)](https://github.com/elastic/kibana/pull/163529) <!--- Backport version: 8.9.7 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Nathan Reese","email":"reese.nathan@elastic.co"},"sourceCommit":{"committedDate":"2023-08-11T19:58:19Z","message":"[controls] fix Dashboard getting stuck at loading in Kibana when Controls is used and mapping changed from integer to keyword (#163529)\n\nCloses https://github.com/elastic/kibana/issues/162474\r\n\r\n### Changes\r\n* RangeSliderEmbeddable - call setInitializationFinished when\r\nrunRangeSliderQuery throws. This fixes the issue\r\n* Investigated if OptionsListEmbeddable is vulnerable to the same issue.\r\nIt's not because it uses its own REST API that has a service wrapper\r\n`OptionsListService`. `OptionsListService` handles REST API errors.\r\n* Add unit test verifying OptionsListService.runOptionsListRequest does\r\nnot throw when there are REST API errors and always returns a response.\r\n* Add unit tests ensuring setInitializationFinished is called for both\r\nRangeSliderEmbeddable and OptionsListEmbeddable in all cases\r\n* Other clean up\r\n* Fix uses of `dataViewsService.get`. `dataViewsService.get` throws when\r\ndata view is not found. It does not return undefined. PR updates\r\nOptionsListEmbeddable, RangeSliderEmbeddable, and mocked data service\r\n* Fix uses of `dataView.getFieldByName`. `dataView.getFieldByName`\r\nreturns undefined when field is not found and never throws. PR updates\r\nOptionsListEmbeddable and RangeSliderEmbeddable\r\n * Remove `resp` wrapper around mocked `fetch` results.\r\n\r\n### Test instructions\r\n1) In console run \r\n ```\r\n PUT test1\r\n\r\n PUT test1/_mapping\r\n {\r\n \"properties\": {\r\n \"value\": {\r\n \"type\": \"integer\"\r\n }\r\n }\r\n }\r\n\r\n PUT test1/_doc/1\r\n {\r\n \"value\" : 1\r\n }\r\n\r\n PUT test1/_doc/2\r\n {\r\n \"value\" : 10\r\n }\r\n ```\r\n2) create data view `test*`\r\n3) create dashboard with range slider control on test*.value.\r\n4) select a range in the range slider\r\n5) save dashboard\r\n6) run the following in console\r\n ```\r\n PUT test2\r\n\r\n PUT test2/_mapping\r\n {\r\n \"properties\": {\r\n \"value\": {\r\n \"type\": \"keyword\"\r\n }\r\n }\r\n }\r\n\r\n PUT test2/_doc/1\r\n {\r\n \"value\" : \"foo\"\r\n }\r\n\r\n DELETE test1\r\n ```\r\n7) Open dashboard saved above. Verify dashboard opens and control\r\ndisplays an error message about being unable to run aggregation on\r\nkeyword field.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: Devon Thomson <devon.thomson@elastic.co>","sha":"0a74fa03a028e7002b5fdebdbfa7dc0c7283aa75","branchLabelMapping":{"^v8.10.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","Feature:Input Control","Team:Presentation","v8.10.0","v8.9.2"],"number":163529,"url":"https://github.com/elastic/kibana/pull/163529","mergeCommit":{"message":"[controls] fix Dashboard getting stuck at loading in Kibana when Controls is used and mapping changed from integer to keyword (#163529)\n\nCloses https://github.com/elastic/kibana/issues/162474\r\n\r\n### Changes\r\n* RangeSliderEmbeddable - call setInitializationFinished when\r\nrunRangeSliderQuery throws. This fixes the issue\r\n* Investigated if OptionsListEmbeddable is vulnerable to the same issue.\r\nIt's not because it uses its own REST API that has a service wrapper\r\n`OptionsListService`. `OptionsListService` handles REST API errors.\r\n* Add unit test verifying OptionsListService.runOptionsListRequest does\r\nnot throw when there are REST API errors and always returns a response.\r\n* Add unit tests ensuring setInitializationFinished is called for both\r\nRangeSliderEmbeddable and OptionsListEmbeddable in all cases\r\n* Other clean up\r\n* Fix uses of `dataViewsService.get`. `dataViewsService.get` throws when\r\ndata view is not found. It does not return undefined. PR updates\r\nOptionsListEmbeddable, RangeSliderEmbeddable, and mocked data service\r\n* Fix uses of `dataView.getFieldByName`. `dataView.getFieldByName`\r\nreturns undefined when field is not found and never throws. PR updates\r\nOptionsListEmbeddable and RangeSliderEmbeddable\r\n * Remove `resp` wrapper around mocked `fetch` results.\r\n\r\n### Test instructions\r\n1) In console run \r\n ```\r\n PUT test1\r\n\r\n PUT test1/_mapping\r\n {\r\n \"properties\": {\r\n \"value\": {\r\n \"type\": \"integer\"\r\n }\r\n }\r\n }\r\n\r\n PUT test1/_doc/1\r\n {\r\n \"value\" : 1\r\n }\r\n\r\n PUT test1/_doc/2\r\n {\r\n \"value\" : 10\r\n }\r\n ```\r\n2) create data view `test*`\r\n3) create dashboard with range slider control on test*.value.\r\n4) select a range in the range slider\r\n5) save dashboard\r\n6) run the following in console\r\n ```\r\n PUT test2\r\n\r\n PUT test2/_mapping\r\n {\r\n \"properties\": {\r\n \"value\": {\r\n \"type\": \"keyword\"\r\n }\r\n }\r\n }\r\n\r\n PUT test2/_doc/1\r\n {\r\n \"value\" : \"foo\"\r\n }\r\n\r\n DELETE test1\r\n ```\r\n7) Open dashboard saved above. Verify dashboard opens and control\r\ndisplays an error message about being unable to run aggregation on\r\nkeyword field.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: Devon Thomson <devon.thomson@elastic.co>","sha":"0a74fa03a028e7002b5fdebdbfa7dc0c7283aa75"}},"sourceBranch":"main","suggestedTargetBranches":["8.9"],"targetPullRequestStates":[{"branch":"main","label":"v8.10.0","labelRegex":"^v8.10.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/163529","number":163529,"mergeCommit":{"message":"[controls] fix Dashboard getting stuck at loading in Kibana when Controls is used and mapping changed from integer to keyword (#163529)\n\nCloses https://github.com/elastic/kibana/issues/162474\r\n\r\n### Changes\r\n* RangeSliderEmbeddable - call setInitializationFinished when\r\nrunRangeSliderQuery throws. This fixes the issue\r\n* Investigated if OptionsListEmbeddable is vulnerable to the same issue.\r\nIt's not because it uses its own REST API that has a service wrapper\r\n`OptionsListService`. `OptionsListService` handles REST API errors.\r\n* Add unit test verifying OptionsListService.runOptionsListRequest does\r\nnot throw when there are REST API errors and always returns a response.\r\n* Add unit tests ensuring setInitializationFinished is called for both\r\nRangeSliderEmbeddable and OptionsListEmbeddable in all cases\r\n* Other clean up\r\n* Fix uses of `dataViewsService.get`. `dataViewsService.get` throws when\r\ndata view is not found. It does not return undefined. PR updates\r\nOptionsListEmbeddable, RangeSliderEmbeddable, and mocked data service\r\n* Fix uses of `dataView.getFieldByName`. `dataView.getFieldByName`\r\nreturns undefined when field is not found and never throws. PR updates\r\nOptionsListEmbeddable and RangeSliderEmbeddable\r\n * Remove `resp` wrapper around mocked `fetch` results.\r\n\r\n### Test instructions\r\n1) In console run \r\n ```\r\n PUT test1\r\n\r\n PUT test1/_mapping\r\n {\r\n \"properties\": {\r\n \"value\": {\r\n \"type\": \"integer\"\r\n }\r\n }\r\n }\r\n\r\n PUT test1/_doc/1\r\n {\r\n \"value\" : 1\r\n }\r\n\r\n PUT test1/_doc/2\r\n {\r\n \"value\" : 10\r\n }\r\n ```\r\n2) create data view `test*`\r\n3) create dashboard with range slider control on test*.value.\r\n4) select a range in the range slider\r\n5) save dashboard\r\n6) run the following in console\r\n ```\r\n PUT test2\r\n\r\n PUT test2/_mapping\r\n {\r\n \"properties\": {\r\n \"value\": {\r\n \"type\": \"keyword\"\r\n }\r\n }\r\n }\r\n\r\n PUT test2/_doc/1\r\n {\r\n \"value\" : \"foo\"\r\n }\r\n\r\n DELETE test1\r\n ```\r\n7) Open dashboard saved above. Verify dashboard opens and control\r\ndisplays an error message about being unable to run aggregation on\r\nkeyword field.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: Devon Thomson <devon.thomson@elastic.co>","sha":"0a74fa03a028e7002b5fdebdbfa7dc0c7283aa75"}},{"branch":"8.9","label":"v8.9.2","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: Nathan Reese <reese.nathan@elastic.co>
This commit is contained in:
parent
b17767646a
commit
075824034c
12 changed files with 471 additions and 104 deletions
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { ControlGroupInput } from '../../../common';
|
||||
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
|
||||
import { storybookFlightsDataView } from '@kbn/presentation-util-plugin/public/mocks';
|
||||
import { OPTIONS_LIST_CONTROL } from '../../../common';
|
||||
import { ControlGroupContainer } from '../../control_group/embeddable/control_group_container';
|
||||
import { pluginServices } from '../../services';
|
||||
import { injectStorybookDataView } from '../../services/data_views/data_views.story';
|
||||
import { OptionsListEmbeddableFactory } from './options_list_embeddable_factory';
|
||||
import { OptionsListEmbeddable } from './options_list_embeddable';
|
||||
|
||||
pluginServices.getServices().controls.getControlFactory = jest
|
||||
.fn()
|
||||
.mockImplementation((type: string) => {
|
||||
if (type === OPTIONS_LIST_CONTROL) return new OptionsListEmbeddableFactory();
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
describe('without selected options', () => {
|
||||
test('should notify control group when initialization is finished', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
// data view not required for test case
|
||||
// setInitializationFinished is called before fetching options when value is not provided
|
||||
injectStorybookDataView(undefined);
|
||||
|
||||
const control = await container.addOptionsListControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'OriginCityName',
|
||||
});
|
||||
|
||||
expect(container.getInput().panels[control.getInput().id].type).toBe(OPTIONS_LIST_CONTROL);
|
||||
expect(container.getOutput().embeddableLoaded[control.getInput().id]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with selected options', () => {
|
||||
test('should set error message when data view can not be found', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
injectStorybookDataView(undefined);
|
||||
|
||||
const control = (await container.addOptionsListControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'OriginCityName',
|
||||
selectedOptions: ['Seoul', 'Tokyo'],
|
||||
})) as OptionsListEmbeddable;
|
||||
|
||||
// await redux dispatch
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
|
||||
const reduxState = control.getState();
|
||||
expect(reduxState.output.loading).toBe(false);
|
||||
expect(reduxState.componentState.error).toBe(
|
||||
'mock DataViews service currentDataView is undefined, call injectStorybookDataView to set'
|
||||
);
|
||||
});
|
||||
|
||||
test('should set error message when field can not be found', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
injectStorybookDataView(storybookFlightsDataView);
|
||||
|
||||
const control = (await container.addOptionsListControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'myField',
|
||||
selectedOptions: ['Seoul', 'Tokyo'],
|
||||
})) as OptionsListEmbeddable;
|
||||
|
||||
// await redux dispatch
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
|
||||
const reduxState = control.getState();
|
||||
expect(reduxState.output.loading).toBe(false);
|
||||
expect(reduxState.componentState.error).toBe('Could not locate field: myField');
|
||||
});
|
||||
|
||||
test('should notify control group when initialization is finished', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
injectStorybookDataView(storybookFlightsDataView);
|
||||
|
||||
const control = await container.addOptionsListControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'OriginCityName',
|
||||
selectedOptions: ['Seoul', 'Tokyo'],
|
||||
});
|
||||
|
||||
expect(container.getInput().panels[control.getInput().id].type).toBe(OPTIONS_LIST_CONTROL);
|
||||
expect(container.getOutput().embeddableLoaded[control.getInput().id]).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -245,13 +245,6 @@ export class OptionsListEmbeddable
|
|||
if (!this.dataView || this.dataView.id !== dataViewId) {
|
||||
try {
|
||||
this.dataView = await this.dataViewsService.get(dataViewId);
|
||||
if (!this.dataView)
|
||||
throw new Error(
|
||||
i18n.translate('controls.optionsList.errors.dataViewNotFound', {
|
||||
defaultMessage: 'Could not locate data view: {dataViewId}',
|
||||
values: { dataViewId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
this.dispatch.setErrorMessage(e.message);
|
||||
}
|
||||
|
@ -260,25 +253,21 @@ export class OptionsListEmbeddable
|
|||
}
|
||||
|
||||
if (this.dataView && (!this.field || this.field.name !== fieldName)) {
|
||||
try {
|
||||
const originalField = this.dataView.getFieldByName(fieldName);
|
||||
if (!originalField) {
|
||||
throw new Error(
|
||||
i18n.translate('controls.optionsList.errors.fieldNotFound', {
|
||||
defaultMessage: 'Could not locate field: {fieldName}',
|
||||
values: { fieldName },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.field = originalField.toSpec();
|
||||
} catch (e) {
|
||||
this.dispatch.setErrorMessage(e.message);
|
||||
const field = this.dataView.getFieldByName(fieldName);
|
||||
if (field) {
|
||||
this.field = field.toSpec();
|
||||
this.dispatch.setField(this.field);
|
||||
} else {
|
||||
this.dispatch.setErrorMessage(
|
||||
i18n.translate('controls.optionsList.errors.fieldNotFound', {
|
||||
defaultMessage: 'Could not locate field: {fieldName}',
|
||||
values: { fieldName },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.dispatch.setField(this.field);
|
||||
}
|
||||
|
||||
return { dataView: this.dataView, field: this.field! };
|
||||
return { dataView: this.dataView, field: this.field };
|
||||
};
|
||||
|
||||
private runOptionsListQuery = async (size: number = MIN_OPTIONS_LIST_REQUEST_SIZE) => {
|
||||
|
|
|
@ -0,0 +1,211 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { of } from 'rxjs';
|
||||
import { ControlGroupInput } from '../../../common';
|
||||
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
|
||||
import { storybookFlightsDataView } from '@kbn/presentation-util-plugin/public/mocks';
|
||||
import { RANGE_SLIDER_CONTROL } from '../../../common';
|
||||
import { ControlGroupContainer } from '../../control_group/embeddable/control_group_container';
|
||||
import { pluginServices } from '../../services';
|
||||
import { injectStorybookDataView } from '../../services/data_views/data_views.story';
|
||||
import { RangeSliderEmbeddableFactory } from './range_slider_embeddable_factory';
|
||||
import { RangeSliderEmbeddable } from './range_slider_embeddable';
|
||||
|
||||
let totalResults = 20;
|
||||
beforeEach(() => {
|
||||
totalResults = 20;
|
||||
|
||||
pluginServices.getServices().controls.getControlFactory = jest
|
||||
.fn()
|
||||
.mockImplementation((type: string) => {
|
||||
if (type === RANGE_SLIDER_CONTROL) return new RangeSliderEmbeddableFactory();
|
||||
});
|
||||
|
||||
pluginServices.getServices().data.searchSource.create = jest.fn().mockImplementation(() => {
|
||||
let isAggsRequest = false;
|
||||
return {
|
||||
setField: (key: string) => {
|
||||
if (key === 'aggs') {
|
||||
isAggsRequest = true;
|
||||
}
|
||||
},
|
||||
fetch$: () => {
|
||||
return isAggsRequest
|
||||
? of({
|
||||
rawResponse: { aggregations: { minAgg: { value: 0 }, maxAgg: { value: 1000 } } },
|
||||
})
|
||||
: of({
|
||||
rawResponse: { hits: { total: { value: totalResults } } },
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
describe('without selected range', () => {
|
||||
test('should notify control group when initialization is finished', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
// data view not required for test case
|
||||
// setInitializationFinished is called before fetching slider range when value is not provided
|
||||
injectStorybookDataView(undefined);
|
||||
|
||||
const control = await container.addRangeSliderControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'AvgTicketPrice',
|
||||
});
|
||||
|
||||
expect(container.getInput().panels[control.getInput().id].type).toBe(RANGE_SLIDER_CONTROL);
|
||||
expect(container.getOutput().embeddableLoaded[control.getInput().id]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with selected range', () => {
|
||||
test('should set error message when data view can not be found', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
injectStorybookDataView(undefined);
|
||||
|
||||
const control = (await container.addRangeSliderControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'AvgTicketPrice',
|
||||
value: ['150', '300'],
|
||||
})) as RangeSliderEmbeddable;
|
||||
|
||||
// await redux dispatch
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
|
||||
const reduxState = control.getState();
|
||||
expect(reduxState.output.loading).toBe(false);
|
||||
expect(reduxState.componentState.error).toBe(
|
||||
'mock DataViews service currentDataView is undefined, call injectStorybookDataView to set'
|
||||
);
|
||||
});
|
||||
|
||||
test('should set error message when field can not be found', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
injectStorybookDataView(storybookFlightsDataView);
|
||||
|
||||
const control = (await container.addRangeSliderControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'myField',
|
||||
value: ['150', '300'],
|
||||
})) as RangeSliderEmbeddable;
|
||||
|
||||
// await redux dispatch
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
|
||||
const reduxState = control.getState();
|
||||
expect(reduxState.output.loading).toBe(false);
|
||||
expect(reduxState.componentState.error).toBe('Could not locate field: myField');
|
||||
});
|
||||
|
||||
test('should set invalid state when filter returns zero results', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
injectStorybookDataView(storybookFlightsDataView);
|
||||
totalResults = 0;
|
||||
|
||||
const control = (await container.addRangeSliderControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'AvgTicketPrice',
|
||||
value: ['150', '300'],
|
||||
})) as RangeSliderEmbeddable;
|
||||
|
||||
// await redux dispatch
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
|
||||
const reduxState = control.getState();
|
||||
expect(reduxState.output.filters?.length).toBe(0);
|
||||
expect(reduxState.componentState.isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
test('should set range and filter', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
injectStorybookDataView(storybookFlightsDataView);
|
||||
|
||||
const control = (await container.addRangeSliderControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'AvgTicketPrice',
|
||||
value: ['150', '300'],
|
||||
})) as RangeSliderEmbeddable;
|
||||
|
||||
// await redux dispatch
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
|
||||
const reduxState = control.getState();
|
||||
expect(reduxState.output.filters?.length).toBe(1);
|
||||
expect(reduxState.output.filters?.[0].query).toEqual({
|
||||
range: {
|
||||
AvgTicketPrice: {
|
||||
gte: 150,
|
||||
lte: 300,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(reduxState.componentState.isInvalid).toBe(false);
|
||||
expect(reduxState.componentState.min).toBe(0);
|
||||
expect(reduxState.componentState.max).toBe(1000);
|
||||
});
|
||||
|
||||
test('should notify control group when initialization is finished', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
injectStorybookDataView(storybookFlightsDataView);
|
||||
|
||||
const control = await container.addRangeSliderControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'AvgTicketPrice',
|
||||
value: ['150', '300'],
|
||||
});
|
||||
|
||||
expect(container.getInput().panels[control.getInput().id].type).toBe(RANGE_SLIDER_CONTROL);
|
||||
expect(container.getOutput().embeddableLoaded[control.getInput().id]).toBe(true);
|
||||
});
|
||||
|
||||
test('should notify control group when initialization throws', async () => {
|
||||
const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage();
|
||||
const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput;
|
||||
const container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput);
|
||||
|
||||
injectStorybookDataView(storybookFlightsDataView);
|
||||
|
||||
pluginServices.getServices().data.searchSource.create = jest.fn().mockImplementation(() => ({
|
||||
setField: () => {},
|
||||
fetch$: () => {
|
||||
throw new Error('Simulated _search request error');
|
||||
},
|
||||
}));
|
||||
|
||||
const control = await container.addRangeSliderControl({
|
||||
dataViewId: 'demoDataFlights',
|
||||
fieldName: 'AvgTicketPrice',
|
||||
value: ['150', '300'],
|
||||
});
|
||||
|
||||
expect(container.getInput().panels[control.getInput().id].type).toBe(RANGE_SLIDER_CONTROL);
|
||||
expect(container.getOutput().embeddableLoaded[control.getInput().id]).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -62,14 +62,6 @@ interface RangeSliderDataFetchProps {
|
|||
validate?: boolean;
|
||||
}
|
||||
|
||||
const fieldMissingError = (fieldName: string) =>
|
||||
new Error(
|
||||
i18n.translate('controls.rangeSlider.errors.fieldNotFound', {
|
||||
defaultMessage: 'Could not locate field: {fieldName}',
|
||||
values: { fieldName },
|
||||
})
|
||||
);
|
||||
|
||||
export const RangeSliderControlContext = createContext<RangeSliderEmbeddable | null>(null);
|
||||
export const useRangeSlider = (): RangeSliderEmbeddable => {
|
||||
const rangeSlider = useContext<RangeSliderEmbeddable | null>(RangeSliderControlContext);
|
||||
|
@ -147,15 +139,14 @@ export class RangeSliderEmbeddable
|
|||
try {
|
||||
await this.runRangeSliderQuery();
|
||||
await this.buildFilter();
|
||||
if (initialValue) {
|
||||
this.setInitializationFinished();
|
||||
}
|
||||
} catch (e) {
|
||||
batch(() => {
|
||||
this.dispatch.setLoading(false);
|
||||
this.dispatch.setErrorMessage(e.message);
|
||||
});
|
||||
this.onLoadingError(e.message);
|
||||
}
|
||||
|
||||
if (initialValue) {
|
||||
this.setInitializationFinished();
|
||||
}
|
||||
|
||||
this.setupSubscriptions();
|
||||
};
|
||||
|
||||
|
@ -182,7 +173,7 @@ export class RangeSliderEmbeddable
|
|||
await this.runRangeSliderQuery();
|
||||
await this.buildFilter();
|
||||
} catch (e) {
|
||||
this.dispatch.setErrorMessage(e.message);
|
||||
this.onLoadingError(e.message);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
@ -209,34 +200,27 @@ export class RangeSliderEmbeddable
|
|||
if (!this.dataView || this.dataView.id !== dataViewId) {
|
||||
try {
|
||||
this.dataView = await this.dataViewsService.get(dataViewId);
|
||||
if (!this.dataView) {
|
||||
throw new Error(
|
||||
i18n.translate('controls.rangeSlider.errors.dataViewNotFound', {
|
||||
defaultMessage: 'Could not locate data view: {dataViewId}',
|
||||
values: { dataViewId },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.dispatch.setDataViewId(this.dataView.id);
|
||||
} catch (e) {
|
||||
this.dispatch.setErrorMessage(e.message);
|
||||
this.onLoadingError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.dataView && (!this.field || this.field.name !== fieldName)) {
|
||||
try {
|
||||
this.field = this.dataView.getFieldByName(fieldName);
|
||||
if (this.field === undefined) {
|
||||
throw fieldMissingError(fieldName);
|
||||
}
|
||||
|
||||
this.field = this.dataView.getFieldByName(fieldName);
|
||||
if (this.field) {
|
||||
this.dispatch.setField(this.field?.toSpec());
|
||||
} catch (e) {
|
||||
this.dispatch.setErrorMessage(e.message);
|
||||
} else {
|
||||
this.onLoadingError(
|
||||
i18n.translate('controls.rangeSlider.errors.fieldNotFound', {
|
||||
defaultMessage: 'Could not locate field: {fieldName}',
|
||||
values: { fieldName },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { dataView: this.dataView, field: this.field! };
|
||||
return { dataView: this.dataView, field: this.field };
|
||||
};
|
||||
|
||||
private runRangeSliderQuery = async () => {
|
||||
|
@ -245,16 +229,6 @@ export class RangeSliderEmbeddable
|
|||
const { dataView, field } = await this.getCurrentDataViewAndField();
|
||||
if (!dataView || !field) return;
|
||||
|
||||
const { fieldName } = this.getInput();
|
||||
|
||||
if (!field) {
|
||||
batch(() => {
|
||||
this.dispatch.setLoading(false);
|
||||
this.dispatch.publishFilters([]);
|
||||
});
|
||||
throw fieldMissingError(fieldName);
|
||||
}
|
||||
|
||||
const embeddableInput = this.getInput();
|
||||
const { ignoreParentSettings, timeRange: globalTimeRange, timeslice } = embeddableInput;
|
||||
let { filters = [] } = embeddableInput;
|
||||
|
@ -278,8 +252,6 @@ export class RangeSliderEmbeddable
|
|||
const { min, max } = await this.fetchMinMax({
|
||||
dataView,
|
||||
field,
|
||||
}).catch((e) => {
|
||||
throw e;
|
||||
});
|
||||
|
||||
this.dispatch.setMinMax({
|
||||
|
@ -332,9 +304,7 @@ export class RangeSliderEmbeddable
|
|||
};
|
||||
searchSource.setField('aggs', aggs);
|
||||
|
||||
const resp = await lastValueFrom(searchSource.fetch$()).catch((e) => {
|
||||
throw e;
|
||||
});
|
||||
const resp = await lastValueFrom(searchSource.fetch$());
|
||||
const min = get(resp, 'rawResponse.aggregations.minAgg.value');
|
||||
const max = get(resp, 'rawResponse.aggregations.maxAgg.value');
|
||||
|
||||
|
@ -397,11 +367,8 @@ export class RangeSliderEmbeddable
|
|||
searchSource.setField('query', query);
|
||||
}
|
||||
|
||||
const {
|
||||
rawResponse: {
|
||||
hits: { total },
|
||||
},
|
||||
} = await lastValueFrom(searchSource.fetch$());
|
||||
const resp = await lastValueFrom(searchSource.fetch$());
|
||||
const total = resp?.rawResponse?.hits?.total;
|
||||
|
||||
const docCount = typeof total === 'number' ? total : total?.value;
|
||||
if (!docCount) {
|
||||
|
@ -425,6 +392,14 @@ export class RangeSliderEmbeddable
|
|||
});
|
||||
};
|
||||
|
||||
private onLoadingError(errorMessage: string) {
|
||||
batch(() => {
|
||||
this.dispatch.setLoading(false);
|
||||
this.dispatch.publishFilters([]);
|
||||
this.dispatch.setErrorMessage(errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
public clearSelections() {
|
||||
this.dispatch.setSelectedRange(['', '']);
|
||||
}
|
||||
|
@ -434,7 +409,7 @@ export class RangeSliderEmbeddable
|
|||
await this.runRangeSliderQuery();
|
||||
await this.buildFilter();
|
||||
} catch (e) {
|
||||
this.dispatch.setErrorMessage(e.message);
|
||||
this.onLoadingError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -19,9 +19,7 @@ export const dataServiceFactory: DataServiceFactory = () => ({
|
|||
setField: () => {},
|
||||
fetch$: () =>
|
||||
of({
|
||||
resp: {
|
||||
rawResponse: { aggregations: { minAgg: { value: 0 }, maxAgg: { value: 1000 } } },
|
||||
},
|
||||
rawResponse: { aggregations: { minAgg: { value: 0 }, maxAgg: { value: 1000 } } },
|
||||
}),
|
||||
}),
|
||||
} as unknown as DataPublicPluginStart['search']['searchSource'],
|
||||
|
|
|
@ -13,17 +13,39 @@ import { ControlsDataViewsService } from './types';
|
|||
|
||||
export type DataViewsServiceFactory = PluginServiceFactory<ControlsDataViewsService>;
|
||||
|
||||
let currentDataView: DataView;
|
||||
export const injectStorybookDataView = (dataView: DataView) => (currentDataView = dataView);
|
||||
let currentDataView: DataView | undefined;
|
||||
export const injectStorybookDataView = (dataView?: DataView) => (currentDataView = dataView);
|
||||
|
||||
export const dataViewsServiceFactory: DataViewsServiceFactory = () => ({
|
||||
get: (() =>
|
||||
new Promise((r) =>
|
||||
setTimeout(() => r(currentDataView), 100)
|
||||
get: ((dataViewId) =>
|
||||
new Promise((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
if (!currentDataView) {
|
||||
reject(
|
||||
new Error(
|
||||
'mock DataViews service currentDataView is undefined, call injectStorybookDataView to set'
|
||||
)
|
||||
);
|
||||
} else if (currentDataView.id === dataViewId) {
|
||||
resolve(currentDataView);
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`mock DataViews service currentDataView.id: ${currentDataView.id} does not match requested dataViewId: ${dataViewId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}, 100)
|
||||
) as unknown) as DataViewsPublicPluginStart['get'],
|
||||
getIdsWithTitle: (() =>
|
||||
new Promise((r) =>
|
||||
setTimeout(() => r([{ id: currentDataView.id, title: currentDataView.title }]), 100)
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
const idsWithTitle: Array<{ id: string | undefined; title: string }> = [];
|
||||
if (currentDataView) {
|
||||
idsWithTitle.push({ id: currentDataView.id, title: currentDataView.title });
|
||||
}
|
||||
resolve(idsWithTitle);
|
||||
}, 100)
|
||||
) as unknown) as DataViewsPublicPluginStart['getIdsWithTitle'],
|
||||
getDefaultId: () => Promise.resolve(currentDataView?.id ?? null),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DataView, FieldSpec } from '@kbn/data-views-plugin/common';
|
||||
import { KibanaPluginServiceParams } from '@kbn/presentation-util-plugin/public';
|
||||
import type { OptionsListRequest } from '../../../common/options_list/types';
|
||||
import type { ControlsPluginStartDeps } from '../../types';
|
||||
import type { ControlsHTTPService } from '../http/types';
|
||||
import type { ControlsDataService } from '../data/types';
|
||||
import { optionsListServiceFactory } from './options_list_service';
|
||||
|
||||
describe('runOptionsListRequest', () => {
|
||||
test('should return OptionsListFailureResponse when fetch throws', async () => {
|
||||
const mockCore = {
|
||||
coreStart: {
|
||||
uiSettings: {
|
||||
get: () => {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as KibanaPluginServiceParams<ControlsPluginStartDeps>;
|
||||
const mockData = {
|
||||
query: {
|
||||
timefilter: {
|
||||
timefilter: {},
|
||||
},
|
||||
},
|
||||
} as unknown as ControlsDataService;
|
||||
const mockHttp = {
|
||||
fetch: () => {
|
||||
throw new Error('Simulated network error');
|
||||
},
|
||||
} as unknown as ControlsHTTPService;
|
||||
const optionsListService = optionsListServiceFactory(mockCore, {
|
||||
data: mockData,
|
||||
http: mockHttp,
|
||||
});
|
||||
|
||||
const response = (await optionsListService.runOptionsListRequest(
|
||||
{
|
||||
dataView: {
|
||||
toSpec: () => {
|
||||
return {};
|
||||
},
|
||||
title: 'myDataView',
|
||||
} as unknown as DataView,
|
||||
field: {
|
||||
name: 'myField',
|
||||
} as unknown as FieldSpec,
|
||||
} as unknown as OptionsListRequest,
|
||||
{} as unknown as AbortSignal
|
||||
)) as any;
|
||||
|
||||
expect(response.error.message).toBe('Simulated network error');
|
||||
});
|
||||
});
|
|
@ -151,6 +151,9 @@ export type OptionsListServiceFactory = KibanaPluginServiceFactory<
|
|||
OptionsListServiceRequiredServices
|
||||
>;
|
||||
|
||||
export const optionsListServiceFactory: OptionsListServiceFactory = (core, requiredServices) => {
|
||||
return new OptionsListService(core.coreStart, requiredServices);
|
||||
export const optionsListServiceFactory: OptionsListServiceFactory = (
|
||||
startParams,
|
||||
requiredServices
|
||||
) => {
|
||||
return new OptionsListService(startParams.coreStart, requiredServices);
|
||||
};
|
||||
|
|
|
@ -61,14 +61,19 @@ const numberFields = [
|
|||
const getConfig = (() => {}) as FieldFormatsGetConfigFn;
|
||||
|
||||
export const flightFieldByName: { [key: string]: DataViewField } = {};
|
||||
flightFieldNames.forEach(
|
||||
(flightFieldName) =>
|
||||
(flightFieldByName[flightFieldName] = {
|
||||
name: flightFieldName,
|
||||
type: numberFields.includes(flightFieldName) ? 'number' : 'string',
|
||||
aggregatable: true,
|
||||
} as unknown as DataViewField)
|
||||
);
|
||||
flightFieldNames.forEach((flightFieldName) => {
|
||||
const fieldBase = {
|
||||
name: flightFieldName,
|
||||
type: numberFields.includes(flightFieldName) ? 'number' : 'string',
|
||||
aggregatable: true,
|
||||
};
|
||||
flightFieldByName[flightFieldName] = {
|
||||
...fieldBase,
|
||||
toSpec: () => {
|
||||
return fieldBase;
|
||||
},
|
||||
} as unknown as DataViewField;
|
||||
});
|
||||
flightFieldByName.Cancelled = { name: 'Cancelled', type: 'boolean' } as DataViewField;
|
||||
flightFieldByName.timestamp = { name: 'timestamp', type: 'date' } as DataViewField;
|
||||
|
||||
|
|
|
@ -399,7 +399,6 @@
|
|||
"controls.controlGroup.manageControl.controlTypeSettings.formGroupDescription": "Paramètres personnalisés pour votre contrôle {controlType}.",
|
||||
"controls.controlGroup.manageControl.controlTypeSettings.formGroupTitle": "Paramètres de {controlType}",
|
||||
"controls.optionsList.controlAndPopover.exists": "{negate, plural, one {Existe} many {Existent} other {Existent}}",
|
||||
"controls.optionsList.errors.dataViewNotFound": "Impossible de localiser la vue de données : {dataViewId}",
|
||||
"controls.optionsList.errors.fieldNotFound": "Impossible de localiser le champ : {fieldName}",
|
||||
"controls.optionsList.popover.ariaLabel": "Fenêtre contextuelle pour le contrôle {fieldName}",
|
||||
"controls.optionsList.popover.cardinalityLabel": "{totalOptions, number} {totalOptions, plural, one {option} many {options disponibles} other {options}}",
|
||||
|
@ -409,7 +408,6 @@
|
|||
"controls.optionsList.popover.invalidSelectionsLabel": "{selectedOptions} {selectedOptions, plural, one {sélection ignorée} many {Sélections ignorées} other {sélections ignorées}}",
|
||||
"controls.optionsList.popover.invalidSelectionsSectionTitle": "{invalidSelectionCount, plural, one {Sélection ignorée} many {Sélections ignorées} other {Sélections ignorées}}",
|
||||
"controls.optionsList.popover.suggestionsAriaLabel": "{optionCount, plural, one {option disponible} many {options disponibles} other {options disponibles}} pour {fieldName}",
|
||||
"controls.rangeSlider.errors.dataViewNotFound": "Impossible de localiser la vue de données : {dataViewId}",
|
||||
"controls.rangeSlider.errors.fieldNotFound": "Impossible de localiser le champ : {fieldName}",
|
||||
"controls.controlGroup.emptyState.addControlButtonTitle": "Ajouter un contrôle",
|
||||
"controls.controlGroup.emptyState.badgeText": "Nouveauté",
|
||||
|
|
|
@ -399,7 +399,6 @@
|
|||
"controls.controlGroup.manageControl.controlTypeSettings.formGroupDescription": "{controlType}コントロールのカスタム設定",
|
||||
"controls.controlGroup.manageControl.controlTypeSettings.formGroupTitle": "{controlType}設定",
|
||||
"controls.optionsList.controlAndPopover.exists": "{negate, plural, other {存在します}}",
|
||||
"controls.optionsList.errors.dataViewNotFound": "データビューが見つかりませんでした:{dataViewId}",
|
||||
"controls.optionsList.errors.fieldNotFound": "フィールドが見つかりませんでした:{fieldName}",
|
||||
"controls.optionsList.popover.ariaLabel": "{fieldName}コントロールのポップオーバー",
|
||||
"controls.optionsList.popover.cardinalityLabel": "{totalOptions, number}{totalOptions, plural, other {オプション}}",
|
||||
|
@ -409,7 +408,6 @@
|
|||
"controls.optionsList.popover.invalidSelectionsLabel": "{selectedOptions}{selectedOptions, plural, other {選択項目}}が無視されました",
|
||||
"controls.optionsList.popover.invalidSelectionsSectionTitle": "{invalidSelectionCount, plural, other {選択項目}}が無視されました",
|
||||
"controls.optionsList.popover.suggestionsAriaLabel": "{fieldName}の{optionCount, plural, other {オプション}}があります",
|
||||
"controls.rangeSlider.errors.dataViewNotFound": "データビューが見つかりませんでした:{dataViewId}",
|
||||
"controls.rangeSlider.errors.fieldNotFound": "フィールドが見つかりませんでした:{fieldName}",
|
||||
"controls.controlGroup.emptyState.addControlButtonTitle": "コントロールを追加",
|
||||
"controls.controlGroup.emptyState.badgeText": "新規",
|
||||
|
|
|
@ -399,7 +399,6 @@
|
|||
"controls.controlGroup.manageControl.controlTypeSettings.formGroupDescription": "{controlType} 控件的定制设置。",
|
||||
"controls.controlGroup.manageControl.controlTypeSettings.formGroupTitle": "{controlType} 设置",
|
||||
"controls.optionsList.controlAndPopover.exists": "{negate, plural, other {存在}}",
|
||||
"controls.optionsList.errors.dataViewNotFound": "找不到数据视图:{dataViewId}",
|
||||
"controls.optionsList.errors.fieldNotFound": "找不到字段:{fieldName}",
|
||||
"controls.optionsList.popover.ariaLabel": "{fieldName} 控件的弹出框",
|
||||
"controls.optionsList.popover.cardinalityLabel": "{totalOptions, number} 个{totalOptions, plural, other {选项}}",
|
||||
|
@ -409,7 +408,6 @@
|
|||
"controls.optionsList.popover.invalidSelectionsLabel": "已忽略 {selectedOptions} 个{selectedOptions, plural, other {选择的内容}}",
|
||||
"controls.optionsList.popover.invalidSelectionsSectionTitle": "已忽略{invalidSelectionCount, plural, other {选择的内容}}",
|
||||
"controls.optionsList.popover.suggestionsAriaLabel": "{fieldName} 的可用{optionCount, plural, other {选项}}",
|
||||
"controls.rangeSlider.errors.dataViewNotFound": "找不到数据视图:{dataViewId}",
|
||||
"controls.rangeSlider.errors.fieldNotFound": "找不到字段:{fieldName}",
|
||||
"controls.controlGroup.emptyState.addControlButtonTitle": "添加控件",
|
||||
"controls.controlGroup.emptyState.badgeText": "新建",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue